StreamPDFStreamPDF
Back to blog

PDF.js Source Walkthrough: crypto

October 27, 2026 · StreamPDF Team

PDF.js Source Walkthrough: crypto

Introduction

This is the cryptography-focused article in the PDF.js source walkthrough series. Its subject is src/core/crypto.js, 1360 lines in total. It is one of the few files in src/core whose main body is algorithms rather than data structures: no page tree, no syntax parsing, just byte permutations, key expansion, hash comparisons, and a small factory that translates a PDF encryption dictionary into runtime dispatch.

The earlier articles took apart streams, the parser, xref and document layer by layer. At the encryption layer the question is no longer "where are the bytes" but "can these bytes be read, with which key, and at which block do we stop". The companion article on DecryptStream only cares about how ciphertext blocks are fed in; this one cares about the key behind those blocks and how it is computed.

PDF encryption is a document-level switch with object-level encryption. The /Encrypt entry in the trailer points at an encryption dictionary that declares the algorithm family, the revision, the key length, the crypt filters, and two password-protected validation values. The actual granularity, however, is the object: every indirect object derives its own object key from its object number and generation, and strings and streams are decrypted separately. Different objects in the same document never share one key stream.

There are two passwords, called the user password and the owner password in the specification. The document stores neither password in plaintext: /O and /U each hold a 32-byte validation value, and newer revisions append a validation salt and a key salt. The user password opens the document; the owner password opens the document and also unlocks the user password, which allows the permissions to be changed. The permission bits in /P are a declaration that a reader may honour, but they are not a cryptographic protection.

There are two generations of mechanism. The older revisions use RC4: the key starts at 40 bits and later grows to 128 bits, encryption is a byte-by-byte XOR, and the state is only 256 bytes. Newer revisions introduce AES: /V 4 crypt filters can select AESV2 (AES-128 in CBC mode), and /V 5 is fixed to AES-256. AES is a block cipher, so streams and strings both have to deal with 16-byte alignment, IVs and PKCS#7 padding.

These algorithms were not handed to the browser's WebCrypto. Not because the authors did not want to, but because they could not: the standard security handler needs primitives that are synchronous, chunkable, and work with arbitrary key lengths; key derivation for R 2 through R 4 runs dozens of MD5 rounds and repeatedly re-encrypts the same check data with RC4; Algorithm 2.B for R 6 needs AES-128-CBC with no padding, consuming only whole blocks. WebCrypto's asynchronous interface, algorithm whitelist and padding policy match none of this. So PDF.js implements RC4, AES-128 and AES-256 itself, and splits MD5, SHA-256, SHA-384 and SHA-512 into separate modules.

In one sentence: crypto.js breaks the "standard security handler" of section 7.6 of the PDF specification into four layers: cipher primitives, password validation, object keys, and a per-object transform facade.

The easiest mistake when reading this file is to follow the order of a cryptography textbook. The source does the opposite: primitives first, then the PDF-specific password algorithms, and finally the factory that turns the encryption dictionary into runtime dispatch. This article follows source order, but every layer is tied back to the dictionary key it corresponds to.

Version note: This article follows exactly the version of crypto.js in the current repository. It exports AES128Cipher, AES256Cipher, ARCFourCipher, CipherTransform, CipherTransformFactory, PDF17 and PDF20; it does not export NullCipher, PDFBase or utf8PasswordToBytes. The factory has no method named buildCipherTransform; the public one is createCipherTransform(num, gen), and the object key is derived by the private method #buildObjectKey. The string methods on the transform object are decryptString and encryptString; the stream method is createStream. /V accepts only 1, 2, 4 and 5, and /V 3 throws immediately. MD5, SHA-256, SHA-384 and SHA-512 are not implemented in this file; they are imported from calculate_md5.js, calculate_sha256.js and calculate_sha_other.js. Do not apply APIs from other versions you remember when reading the source.

Module Overview

crypto.js splits into seven parts:

BlockLine rangeKey classes and methodsResponsibility
Symmetric primitives53-111ARCFourCipher, NullCipherRC4 KSA/PRGA plus the identity "no encryption" implementation
AES base class113-525AESBaseCipherS-box, T-tables, round functions, CBC chaining and padding
AES key expansion527-654AES128Cipher, AES256CipherRound key generation for 10 and 14 rounds
Password algorithms656-756PDFBase, PDF17, PDF20R5/R6 O/U validation, file key unwrapping, Algorithm 2.B
Transform facade758-855CipherTransformFilter dispatch, streaming decryption, string encryption
Password encoding857-864utf8PasswordToBytesJavaScript string to UTF-8 bytes
Security handler factory866-1350CipherTransformFactoryParse the encryption dictionary, validate the password, derive the file key, build transforms

Only five entry points are actually called from other modules:

Entry pointCallerPurpose
new CipherTransformFactory(dict, fileId, pwd)XRef.parseOne per document; parses the dictionary and computes the file key
factory.encryptionKeyCipherTransformFactory itselfFile-level key used for object key derivation
factory.createCipherTransform(num, gen)XRef.fetchUncompressedOne transform per indirect object
transform.decryptString / transform.createStreamParserReading strings and streams
transform.encryptStringWriterWriting strings and streams back

Where it sits in the chain:

trailer /Encrypt
  → XRef.parse
  → new CipherTransformFactory(dict, fileId, password)
  → #prepareKeyData / #createEncryptionKey20        (validate the password, obtain encryptionKey)
  → XRef.fetchUncompressed(ref)
  → createCipherTransform(num, gen)                 (derive the object key from the object number)
  → Parser(cipherTransform)
      → decryptString                               (strings, decrypted in one pass)
      → createStream → DecryptStream → decryptBlock (streams, decrypted block by block)
  → Dict / BaseStream

Writing back is the symmetric path:

Writer.writeObject
  → encrypt.createCipherTransform(ref.num, ref.gen)
  → transform.encryptString(string)
  → write out the encrypted string or stream

Both paths share one factory, but the factory does not remember the "current object". The object number and generation are passed in on every call; that is why CipherTransformFactory has a single private field, #fileId, while CipherTransform caches only the "filter name to cipher constructor" mapping. Encryption state is deliberately squeezed to a minimum: one file key, object keys computed on demand, and a cipher instance created per stream or per string.

Design note: The layering in this file is not by "algorithm difficulty" but by lifetime. The file key lives as long as the document; an object key lives for a single fetch; a cipher instance (RC4's 256-byte permutation table, AES's IV and residual block) lives for one stream or one string. Promote any of the three to a longer lifetime and streaming decryption loses its ordering guarantee. This constraint will keep showing up in the caching, binding and instantiation decisions below.

Mapping the Encryption Dictionary to Classes

The Factory's Entry Point

PDF encryption parameters do not live in a page; they live on the /Encrypt entry of the trailer. After XRef.parse reads the trailer, it parses /Encrypt into a dictionary, takes the first element of the /ID array as the file identifier, and hands both plus the user password to the factory:

if (encrypt instanceof Dict) {
  const ids = trailerDict.get('ID');
  const fileId = ids?.length ? ids[0] : '';
  // The 'Encrypt' dictionary itself should not be encrypted, and by
  // setting `suppressEncryption` we can prevent an infinite loop inside
  // of `XRef_fetchUncompressed` if the dictionary contains indirect
  // objects (fixes issue7665.pdf).
  encrypt.suppressEncryption = true;
  this.encrypt = new CipherTransformFactory(encrypt, fileId, this.pdfManager.password);
}

Three signals in this snippet map exactly onto the factory's three constructor arguments:

  1. The /Encrypt dictionary itself must not take part in decryption. Its /O and /U are validation values, and /CF may contain indirect objects; if reading the encryption dictionary required decryption first, the result would be a deadlock. suppressEncryption is the circuit breaker for this path, and #prepareKeyData applies the same treatment to /CF.
  2. fileId comes from /ID[0] and may be empty. It participates in key derivation: the same password and the same permissions still produce a different file key when the file identifier differs. It is the salt that prevents one key from working everywhere.
  3. pdfManager.password may be empty. An empty password does not mean "not encrypted"; it means "the user has not typed one yet". The factory must distinguish "no password" from "wrong password", because the two produce different prompts in the UI.

A V 4, R 4, AESV2 encryption dictionary looks like this (structural sketch; binary values are described in words):

// Structural sketch of an encryption dictionary, not a runnable PDF parse result
const encryptDict = {
  Filter: 'Standard',
  V: 4,
  R: 4,
  Length: 128,
  CF: {
    StdCF: {
      CFM: 'AESV2',
      AuthEvent: 'DocOpen',
      Length: 16,
    },
  },
  StmF: 'StdCF',
  StrF: 'StdCF',
  EFF: 'StdCF',
  O: '32-byte validation value + 8-byte validation salt + 8-byte key salt',
  U: '32-byte validation value + 8-byte validation salt + 8-byte key salt',
  P: -3904,
  EncryptMetadata: true,
};

Note that Length is in bits, while the Length of a specific handler inside /CF is in bytes according to the specification. PDF.js corrects for both, as the constructor discussion below will show.

Dictionary Keys to Fields

The factory constructor essentially copies that table into instance fields:

Encryption dictionary keySource field / local variableMeaningUsed by
/FilterfilterNameSecurity handler name, must be StandardConstructor entry validation
/ValgorithmEncryption mechanism version, only 1, 2, 4, 5 allowedConstructor, createCipherTransform
/RrevisionPassword algorithm revision, commonly 2, 3, 4, 5, 6#prepareKeyData, #createEncryptionKey20
/LengthkeyLengthFile key length in bits#prepareKeyData, #buildObjectKey
/CFcfCrypt filter dictionary, read only when V is 4 or higherresolveCipher
/StmFstmfDefault crypt filter name for streamsstreamFilterName of CipherTransform
/StrFstrfDefault crypt filter name for stringsstringFilterName of CipherTransform
/EFFeffDefault crypt filter for embedded files, defaults to StmFCipherTransform.embeddedFilterName
/OownerBytes, ownerPasswordOwner password validation value; first 32 bytes validate, the next 16 are saltsBoth password paths
/UuserBytes, userPasswordUser password validation value; first 32 bytes validate, later bytes are saltsBoth password paths
/OEownerEncryptionFile key encrypted with the owner key, only for V 5#createEncryptionKey20
/UEuserEncryptionFile key encrypted with the user key, only for V 5#createEncryptionKey20
/PermspermsEncrypted copy of the permissions, only for V 5Passed to #createEncryptionKey20, unused in this version
/PflagsPermission bits, read as a 32-bit signed integerHash input in #prepareKeyData
/EncryptMetadataencryptMetadataWhether metadata streams are encrypted; meaningful for V 4, V 5Hash input, stream parameters in Catalog

It is easy to miss that /P is part of the hash. It means that changing only the permission bits without changing the password makes the validation value stop matching. The permission declaration is therefore bound into key derivation instead of being a configuration table anyone can edit.

/V and /R Are Two Independent Axes

Beginners tend to treat /V and /R as two spellings of the same "version number". They describe two different things:

  • /V selects the mechanism: whether crypt filters are used, RC4 or AES, and how long the file key is.
  • /R selects the password algorithm: how /O and /U are computed, how long the validation values are, and how many rounds of key derivation run.

The same mechanism can be paired with different revisions, so the factory stacks two branches: algorithm picks the key derivation path, then revision decides the hash function and the iteration count. The supported range as a table:

/VMechanismPer-object keyCommon /RFile key length
1RC4, fixed at 40 bitsYes25 bytes
2RC4, length given by /LengthYes35 to 16 bytes
4Crypt filters, CFM may be V2, AESV2 or NoneYes4Usually 16 bytes
5AES-256, always uses the file keyNo5, 632 bytes

/V 3 is the older way of writing "RC4 128-bit" in the specification, but this version of PDF.js rejects it outright; the constructor accepts only 1, 2, 4 and 5. Do not assume a branch is missing when reading the source: the comment at the end of createCipherTransform says algorithms 1 and 2, and V 3 never reaches it.

The supported password algorithms:

/RPassword algorithmValidation value layoutKey length
2Algorithm 2: one MD5 pass, one RC4 pass32 bytes each in /O and /U40 bits
3Strengthened Algorithm 2: MD5 fifty times, RC4 twenty rounds32 bytes each in /O and /U40 to 128 bits
4Same as R 3, plus crypt filters and /EncryptMetadata32 bytes each in /O and /UUsually 128 bits
5SHA-256 validation, separated salts, /OE and /UE encrypt the file key48 bytes each in /O and /U, plus /OE, /UE256 bits
6Starts with SHA-256, Algorithm 2.B iterations, password prepped with SASLprepSame as R 5256 bits

From the Dictionary to Runtime Objects

Mapping the table above onto classes gives this:

Dictionary informationRuntime carrier
Filter StandardCipherTransformFactory.filterName
V, R, Lengthalgorithm, revision, the derived encryptionKey
CF, StmF, StrF, EFFcf, stmf, strf, eff, which end up in CipherTransform
O, U, OE, UE, PLocal byte arrays in the constructor, fed to #prepareKeyData or #createEncryptionKey20
CFM V2ARCFourCipher with the object key
CFM AESV2AES128Cipher with the object key
CFM AESV3 or V 5AES256Cipher with the file key
CFM None or absentNullCipher
PasswordConstructor argument, only used for validation, never stored

This table answers a common question: why does V 5 have no per-object key? Because the 32-byte AES-256 file key is already strong enough, and the specification uses it directly for every stream and string without object-number derivation; AESV3 in /CF only declares "AES-256 is used here" and does not change where the key comes from. V 4 is the opposite: the file key is usually only 16 bytes, so it must be derived through the object number and generation to avoid reusing one key across all objects.

Design note: The encryption dictionary is declarative, while the class structure is dispatch-oriented. The dictionary describes parameters, not flow; the factory turns the parameters into a one-time file key, then packages "how to pick a cipher and how to derive an object key" into a closure handed to CipherTransform. As a result CipherTransform never needs to know about /V, /R or /Length; it only needs to know "give me a filter name and I give you back a cipher constructor".

ARCFourCipher: A 256-Byte State Machine

RC4 is the oldest cipher in PDF and the shortest implementation in this file. It is a stream cipher: no block boundaries, no padding, and the key stream is produced byte by byte from a 256-byte permutation table, so plaintext XOR key stream gives ciphertext. PDF used it from R 2 through R 4, with key lengths between 5 and 16 bytes; the CFM V2 of R 4 is still RC4.

KSA: Shuffling 256 Bytes with the Key

RC4 has two steps: initialise the permutation table with the key (KSA), then emit the key stream byte by byte (PRGA). The constructor only performs the first:

class ARCFourCipher {
  a = 0;
 
  b = 0;
 
  constructor(key) {
    const s = new Uint8Array(256);
    const keyLength = key.length;
 
    for (let i = 0; i < 256; ++i) {
      s[i] = i;
    }
    for (let i = 0, j = 0; i < 256; ++i, ++j) {
      const tmp = s[i];
      j = (j + tmp + key[i % keyLength]) & 0xff;
      s[i] = s[j];
      s[j] = tmp;
    }
    this.s = s;
  }

Line by line:

  1. Build an identity permutation first: s[i] = i, 256 bytes covering 0 to 255 exactly.
  2. j starts at 0, and each round adds the current byte s[i] plus the key byte key[i % keyLength]. The key is usually shorter than 256, so it is cycled through i % keyLength.
  3. & 0xff truncates the accumulated value back into one byte. This is not a casual "modulo optimisation"; it is part of the RC4 definition, and every index must land in the range 0 to 255.
  4. Swap s[i] with s[j]. After 256 rounds, s is a permutation of 0 to 255.

The loop is not written in the textbook shape of "compute j, then swap". It stores s[i] in tmp first, then writes s[j] and s[i]. That order is mandatory: s[i] = s[j] would overwrite the original value, so writing s[i] before reading s[j] would read the wrong byte. The source picks the safest temporary-variable form.

keyLength is not validated in this class. It may be 5, 16 or 32; the KSA only cares that the key is non-empty and that it cycles by length. Whether a key is legal is the job of CipherTransformFactory upstream; the primitive only computes.

PRGA: Advancing a Little on Every Call

After initialisation, the two cursors a and b are kept as instance fields, and encryptBlock advances them for each chunk:

  encryptBlock(data) {
    let a = this.a,
      b = this.b;
    const s = this.s;
    const n = data.length;
    const output = new Uint8Array(n);
    for (let i = 0; i < n; ++i) {
      a = (a + 1) & 0xff;
      const tmp = s[a];
      b = (b + tmp) & 0xff;
      const tmp2 = s[b];
      s[a] = tmp2;
      s[b] = tmp;
      output[i] = data[i] ^ s[(tmp + tmp2) & 0xff];
    }
    this.a = a;
    this.b = b;
    return output;
  }

This is the standard PRGA:

  • a increments per byte, guaranteeing a full walk across the table.
  • Take s[a], add it to b, then take s[b].
  • Swap s[a] and s[b] so the table keeps evolving.
  • The output byte is the input byte XOR s[(s[a] + s[b]) & 0xff]. In the source, tmp and tmp2 are exactly the two values before the swap, which avoids a second table lookup.

Both cursors are loaded into locals at the top and written back at the bottom. This is the usual hot-path pattern: locals avoid repeated property access, and the state is synchronised once when the loop ends. If an exception were thrown mid-loop the state would not be written back and the next call would resume from the old position; this loop cannot throw, so no rollback is needed.

The state must survive across calls. This is the biggest difference between RC4 and AES: AES can be invoked block by block independently, while RC4's key stream position depends entirely on a, b and the current permutation table. When a 10 MB stream arrives as 512-byte chunks, chunk 2 must continue from chunk 1's a and b; constructing a fresh cipher object each time would restart the key stream and produce garbage. This is also the fundamental reason CipherTransform caches constructors rather than instances, as discussed later.

decryptBlock and encrypt

RC4 encryption and decryption are the same operation: XOR the ciphertext with the same key stream and you are back to plaintext. The source does not write a separate decryption loop:

  decryptBlock(data) {
    return this.encryptBlock(data);
  }
 
  encrypt(data) {
    return this.encryptBlock(data);
  }

The three names serve two calling conventions:

MethodParametersBehaviourCallers
encryptBlockByte arrayAdvances the key stream and XORsInternally, RC4 rounds in password algorithms
decryptBlockByte array (second argument ignored)Delegates to encryptBlockDecryptStream, decryptString
encryptByte arrayDelegates to encryptBlockencryptString, object key validation

decryptBlock accepts only one parameter, so the finalize passed by DecryptStream is silently ignored by JavaScript. This is not an oversight, it is simply not needed: RC4 has no blocks and no padding to finalise.

RC4 has another easily forgotten property: the same key must not be reused at the start of two streams. If two pieces of content are encrypted with the same object key starting from a = 0 and b = 0, their opening key streams are identical, and XORing the two ciphertexts cancels the key stream. Deriving object keys from object number and generation exists precisely to prevent this reuse, and creating a new cipher instance on every createStream is what makes each stream start clean.

Design note: The RC4 implementation is only 49 lines, yet it makes the statefulness of a stream cipher unmistakable. The constructor establishes the initial state; the actual advancing happens in encryptBlock; encryption and decryption share one loop because XOR is its own inverse. Any wrapper that turns the cipher object into a stateless utility function breaks the moment reads are chunked.

NullCipher: Making "No Encryption" an Object

NullCipher is eight lines:

class NullCipher {
  decryptBlock(data) {
    return data;
  }
 
  encrypt(data) {
    return data;
  }
}

Its semantics are identity: whatever goes in comes out, without even copying the array. decryptBlock likewise ignores the second argument, because "not encrypted" has neither block boundaries nor padding.

Why is a do-nothing class needed instead of checking for null at the layer above? Because too many places on the decryption path need a uniform interface:

  • CFM is missing, or explicitly None: resolveCipher returns NullCipher.
  • /StmF or /StrF default to Identity, and the referenced filter has no CFM, which also lands on NullCipher.
  • The DecryptStream callback only knows decryptBlock and does not care whether RC4, AES or identity sits behind it.

If the upper layers all wrote "decrypt when there is a cipher object, skip otherwise", then Parser, DecryptStream and CipherTransform would each repeat the check, and one path would eventually miss it. Modelling "not encrypted" as a cipher object means callers only see a uniform decryptBlock(data, finalize), and the branching collapses into one place: the factory's resolveCipher.

Cipher classTypeInstance stateInput/output lengthTypical origin
ARCFourCipherStream cipher256-byte permutation table, two cursorsEqualV 1, V 2, CFM V2
AES128CipherBlock cipher176-byte round keys, 16-byte residual, IVWhole blocks, last block unpaddedCFM AESV2
AES256CipherBlock cipher240-byte round keys, 16-byte residual, IVWhole blocks, last block unpaddedV 5, CFM AESV3
NullCipherIdentityNoneEqual, and no copyCFM None, Identity

AESBaseCipher: Table-Driven AES

AES is a block cipher with a fixed 16-byte block. The state is a 4 by 4 byte matrix laid out column-major: state[0] through state[3] are the first column, state[4] through state[7] the second, and so on. PDF uses CBC mode, so beyond the round functions it also has to handle IV chaining, block buffering and padding on the last block.

AESBaseCipher collects everything AES-128 and AES-256 share into the base class:

  • Four lookup tables: the S-box, the inverse S-box, the inverse MixColumns 32-bit table, and the doubling table.
  • The encryption and decryption round functions.
  • CBC buffering, IV chaining and decryption padding handling.
  • The lazy logic that reads the IV from the head of the ciphertext on the first call.

The subclasses supply only two things: the round count and the key expansion. That keeps the difference between 128 and 256 bits minimal, so a reader can clearly see that "AES itself" and "key length" are separate concerns.

The Four Tables

The first is the S-box, 256 bytes, used by SubBytes and InvSubBytes:

_s = new Uint8Array([
  0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76,
  0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0,
  // the remaining 240 entries are omitted
]);

The second is its inverse _inv_s, also 256 entries. Neither table is computed at runtime; both are written as literals. That trades size for startup time, the standard approach in cipher implementations.

The third is _mix, a Uint32Array with 256 entries. It is not an ordinary S-box: it packs the coefficients of inverse MixColumns into a 32-bit integer:

_mix = new Uint32Array([
  0x00000000, 0x0e090d0b, 0x1c121a16, 0x121b171d, 0x3824342c, 0x362d3927,
  // the remaining 250 entries are omitted
]);

Entry 0 is 0, entry 1 is 0x0e090d0b, entry 2 is 0x1c121a16. Splitting 0x0e090d0b into bytes gives 0e, 09, 0d and 0b, which are the four coefficients of the first inverse MixColumns column multiplied by input byte 1 over GF(2^8); entry 2 is each coefficient multiplied by 2. In other words, _mix[x] gives "the full contribution of input byte x to the four output rows" in one go, and the round function then XORs four such 32-bit values with byte shifts to complete one InvMixColumns.

The fourth is _mixCol, 256 bytes, computed directly by a class field expression:

_mixCol = Uint8Array.from({ length: 256 }, (_, i) => (i < 128 ? i << 1 : (i << 1) ^ 0x1b));

This is AES's xtime: multiply by 2. Below 128 a single left shift suffices; at 128 or above the shift overflows 8 bits and needs an additional XOR with 0x1b (the low byte of the AES irreducible polynomial). The table feeds MixColumns in the encryption rounds.

ConstantTypeEntriesPurpose
_sUint8Array256SubBytes, byte substitution
_inv_sUint8Array256InvSubBytes, inverse substitution when decrypting
_mixUint32Array256Column contributions for InvMixColumns, packed into 32 bits
_mixColUint8Array256xtime, multiplication by 2 when encrypting

Construction and Two Abstract Points

The base constructor does just two things: refuse direct instantiation, and prepare a 16-byte input buffer:

  constructor() {
    if (
      (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) &&
      this.constructor === AESBaseCipher
    ) {
      unreachable("Cannot initialize AESBaseCipher.");
    }
    this.buffer = new Uint8Array(16);
    this.bufferPosition = 0;
  }
 
  _expandKey(cipherKey) {
    unreachable("Cannot call `_expandKey` on the base class");
  }

buffer holds input that is not yet a full 16 bytes; bufferPosition records how many bytes it already contains and is set to 0 in the constructor. _expandKey is the only abstract method; calling it on the base class only triggers unreachable.

Subclasses fill in three fields when they construct:

FieldAES-128AES-256Meaning
_cyclesOfRepetition1014Round count, decides how many times the round loop runs
_keySize160224Byte offset of the last round key in the expanded key
_key176 bytes240 bytesThe expanded round keys

The source comment describes _keySize as bits, but it is used directly as a byte index in _decrypt and _encrypt: key[this._keySize] is where the final 16 bytes begin. Read it as a byte offset; do not be misled by the comment.

The Decryption Round Function

_decrypt is one of the longest single methods in the file. The full implementation:

  _decrypt(input, key) {
    let t, u, v;
    const state = new Uint8Array(16);
    state.set(input);
 
    // AddRoundKey
    for (let j = 0, k = this._keySize; j < 16; ++j, ++k) {
      state[j] ^= key[k];
    }
    for (let i = this._cyclesOfRepetition - 1; i >= 1; --i) {
      // InvShiftRows
      t = state[13];
      state[13] = state[9];
      state[9] = state[5];
      state[5] = state[1];
      state[1] = t;
      t = state[14];
      u = state[10];
      state[14] = state[6];
      state[10] = state[2];
      state[6] = t;
      state[2] = u;
      t = state[15];
      u = state[11];
      v = state[7];
      state[15] = state[3];
      state[11] = t;
      state[7] = u;
      state[3] = v;
      // InvSubBytes
      for (let j = 0; j < 16; ++j) {
        state[j] = this._inv_s[state[j]];
      }
      // AddRoundKey
      for (let j = 0, k = i * 16; j < 16; ++j, ++k) {
        state[j] ^= key[k];
      }
      // InvMixColumns
      for (let j = 0; j < 16; j += 4) {
        const s0 = this._mix[state[j]];
        const s1 = this._mix[state[j + 1]];
        const s2 = this._mix[state[j + 2]];
        const s3 = this._mix[state[j + 3]];
        t =
          s0 ^
          (s1 >>> 8) ^
          (s1 << 24) ^
          (s2 >>> 16) ^
          (s2 << 16) ^
          (s3 >>> 24) ^
          (s3 << 8);
        state[j] = (t >>> 24) & 0xff;
        state[j + 1] = (t >> 16) & 0xff;
        state[j + 2] = (t >> 8) & 0xff;
        state[j + 3] = t & 0xff;
      }
    }
    // InvShiftRows
    t = state[13];
    state[13] = state[9];
    state[9] = state[5];
    state[5] = state[1];
    state[1] = t;
    t = state[14];
    u = state[10];
    state[14] = state[6];
    state[10] = state[2];
    state[6] = t;
    state[2] = u;
    t = state[15];
    u = state[11];
    v = state[7];
    state[15] = state[3];
    state[11] = t;
    state[7] = u;
    state[3] = v;
    for (let j = 0; j < 16; ++j) {
      // InvSubBytes
      state[j] = this._inv_s[state[j]];
      // AddRoundKey
      state[j] ^= key[j];
    }
    return state;
  }

This code runs the equivalent inverse cipher of AES rather than simply reversing the encryption steps. The order is:

  1. AddRoundKey with the last round key first, starting at index _keySize.
  2. Middle rounds count down from _cyclesOfRepetition - 1 to 1, each performing InvShiftRows, InvSubBytes, AddRoundKey (round i key), then InvMixColumns.
  3. The final round performs only InvShiftRows, InvSubBytes and AddRoundKey (the first round key), with no InvMixColumns.

The key to the equivalent inverse cipher is placing InvMixColumns after AddRoundKey. That way decryption can use the very same round keys generated during encryption, without applying inverse MixColumns to the intermediate round keys again. The price is that round keys are consumed in reverse order, which is why the structure "adds the last key first and then walks backwards".

InvShiftRows is implemented as a per-row rotate-right: row 1 by 1, row 2 by 2, row 3 by 3. Because the state is stored column-major, row 1 holds indices 1, 5, 9, 13, row 2 holds 2, 6, 10, 14, and row 3 holds 3, 7, 11, 15. Rather than looping over indices, the source unrolls the three swap chains:

t = state[13];
state[13] = state[9];
state[9] = state[5];
state[5] = state[1];
state[1] = t;

That is the rotate-right of row 1: 13 → 9 → 5 → 1, finally putting the original 13 back into position 1. Row 2 works the same way, using two temporaries t and u for the four-element cycle; row 3 uses t, u and v for the three-step rotation. Unrolling saves the loop and the modulo, at the cost of code that looks slightly obscure.

InvMixColumns is the most tricksy part of the whole block. It works on 4 bytes at a time, looks up four 32-bit values in _mix, then XORs them with byte shifts:

t = s0 ^ (s1 >>> 8) ^ (s1 << 24) ^ (s2 >>> 16) ^ (s2 << 16) ^ (s3 >>> 24) ^ (s3 << 8);

Each _mix[x] already contains "the contribution of byte x to the four output rows", and the four values correspond to the four input bytes of this column. The shifts align those four contributions onto the same output bytes: >>> 8, >>> 16 and >>> 24 bring contributions from different column positions down to the low bits, while << 24, << 16 and << 8 push the overflowed parts back to the high bits. The resulting 32-bit value is split big-endian into 4 bytes and written back to state.

This table lookup is much faster than doing GF(2^8) multiplication byte by byte; the price is that _mix occupies 1 KB. For a reader that decrypts large numbers of streams outside the browser's main thread, the trade is worth it.

The Encryption Round Function

_encrypt mirrors _decrypt, but the round order is reversed:

  _encrypt(input, key) {
    const s = this._s;
 
    let t, u, v;
    const state = new Uint8Array(16);
    state.set(input);
 
    for (let j = 0; j < 16; ++j) {
      // AddRoundKey
      state[j] ^= key[j];
    }
 
    for (let i = 1; i < this._cyclesOfRepetition; i++) {
      // SubBytes
      for (let j = 0; j < 16; ++j) {
        state[j] = s[state[j]];
      }
      // ShiftRows
      v = state[1];
      state[1] = state[5];
      state[5] = state[9];
      state[9] = state[13];
      state[13] = v;
      v = state[2];
      u = state[6];
      state[2] = state[10];
      state[6] = state[14];
      state[10] = v;
      state[14] = u;
      v = state[3];
      u = state[7];
      t = state[11];
      state[3] = state[15];
      state[7] = v;
      state[11] = u;
      state[15] = t;
      // MixColumns
      for (let j = 0; j < 16; j += 4) {
        const s0 = state[j];
        const s1 = state[j + 1];
        const s2 = state[j + 2];
        const s3 = state[j + 3];
        t = s0 ^ s1 ^ s2 ^ s3;
        state[j] ^= t ^ this._mixCol[s0 ^ s1];
        state[j + 1] ^= t ^ this._mixCol[s1 ^ s2];
        state[j + 2] ^= t ^ this._mixCol[s2 ^ s3];
        state[j + 3] ^= t ^ this._mixCol[s3 ^ s0];
      }
      // AddRoundKey
      for (let j = 0, k = i * 16; j < 16; ++j, ++k) {
        state[j] ^= key[k];
      }
    }
 
    // SubBytes
    for (let j = 0; j < 16; ++j) {
      state[j] = s[state[j]];
    }
    // ShiftRows
    v = state[1];
    state[1] = state[5];
    state[5] = state[9];
    state[9] = state[13];
    state[13] = v;
    v = state[2];
    u = state[6];
    state[2] = state[10];
    state[6] = state[14];
    state[10] = v;
    state[14] = u;
    v = state[3];
    u = state[7];
    t = state[11];
    state[3] = state[15];
    state[7] = v;
    state[11] = u;
    state[15] = t;
    // AddRoundKey
    for (let j = 0, k = this._keySize; j < 16; ++j, ++k) {
      state[j] ^= key[k];
    }
    return state;
  }

Encryption has three stages:

  1. Initial key addition. The plaintext is XORed with the first round key, starting at index 0.
  2. Middle rounds. From 1 to _cyclesOfRepetition - 1, each round performs SubBytes, ShiftRows, MixColumns, AddRoundKey (round i key).
  3. Final round. Only SubBytes, ShiftRows and AddRoundKey (the last round key, at index _keySize), without MixColumns.

ShiftRows is the reverse of InvShiftRows: row 1 rotates left by 1, row 2 by 2, row 3 by 3. The source again unrolls by row:

v = state[1];
state[1] = state[5];
state[5] = state[9];
state[9] = state[13];
state[13] = v;

Row 1's chain is 1 → 5 → 9 → 13, exactly opposite to decryption's 13 → 9 → 5 → 1. The three rows have cycles of different lengths, so row 1 needs one temporary, row 2 needs two, and row 3 needs three.

MixColumns does not consult _mix; instead it uses an optimised form based on xtime from _mixCol:

t = s0 ^ s1 ^ s2 ^ s3;
state[j] ^= t ^ this._mixCol[s0 ^ s1];
state[j + 1] ^= t ^ this._mixCol[s1 ^ s2];
state[j + 2] ^= t ^ this._mixCol[s2 ^ s3];
state[j + 3] ^= t ^ this._mixCol[s3 ^ s0];

In the standard definition, the output of one column is:

  • Row 0: 2·s0 ^ 3·s1 ^ s2 ^ s3
  • Row 1: s0 ^ 2·s1 ^ 3·s2 ^ s3
  • Row 2: s0 ^ s1 ^ 2·s2 ^ 3·s3
  • Row 3: 3·s0 ^ s1 ^ s2 ^ 2·s3

Writing 3·x as 2·x ^ x and factoring out the shared s0 ^ s1 ^ s2 ^ s3 gives the source form: t is the XOR of the four bytes, and _mixCol[s0 ^ s1] is 2·(s0 ^ s1). Because multiplication over GF(2^8) distributes over XOR, 2·s0 ^ 2·s1 equals 2·(s0 ^ s1), so each row needs only one table lookup.

Putting the two round functions side by side makes the trade-off of the equivalent inverse cipher clear:

StageEncryptionDecryption
StartAddRoundKey(key[0..16])AddRoundKey(key[_keySize..])
Middle roundsSubBytes, ShiftRows, MixColumns, AddRoundKeyInvShiftRows, InvSubBytes, AddRoundKey, InvMixColumns
Final roundSubBytes, ShiftRows, AddRoundKeyInvShiftRows, InvSubBytes, AddRoundKey
Key orderFirst round to last roundLast round to first round

CBC Decryption and Padding

A block cipher only transforms 16 bytes at a time; CBC mode also has to chain the blocks. When decrypting, each block is decrypted independently and then XORed with the previous ciphertext block:

  _decryptBlock2(data, finalize) {
    const sourceLength = data.length;
    let buffer = this.buffer,
      bufferLength = this.bufferPosition;
    const result = [];
    let iv = this.iv;
 
    for (let i = 0; i < sourceLength; ++i) {
      buffer[bufferLength] = data[i];
      ++bufferLength;
      if (bufferLength < 16) {
        continue;
      }
      // buffer is full, decrypting
      const plain = this._decrypt(buffer, this._key);
      // xor-ing the IV vector to get plain text
      for (let j = 0; j < 16; ++j) {
        plain[j] ^= iv[j];
      }
      iv = buffer;
      result.push(plain);
      buffer = new Uint8Array(16);
      bufferLength = 0;
    }
    // saving incomplete buffer
    this.buffer = buffer;
    this.bufferLength = bufferLength;
    this.iv = iv;
    if (result.length === 0) {
      return new Uint8Array(0);
    }
    // combining plain text blocks into one
    let outputLength = 16 * result.length;
    if (finalize) {
      // undo a padding that is described in RFC 2898
      const lastBlock = result.at(-1);
      let psLen = lastBlock[15];
      if (psLen <= 16) {
        for (let i = 15, ii = 16 - psLen; i >= ii; --i) {
          if (lastBlock[i] !== psLen) {
            // Invalid padding, assume that the block has no padding.
            psLen = 0;
            break;
          }
        }
        outputLength -= psLen;
        result[result.length - 1] = lastBlock.subarray(0, 16 - psLen);
      }
    }
    const output = new Uint8Array(outputLength);
    for (let i = 0, j = 0, ii = result.length; i < ii; ++i, j += 16) {
      output.set(result[i], j);
    }
    return output;
  }

This code has three levels of logic:

Level one is buffering. The input may not be a multiple of 16, so bytes are pushed into buffer and processed only when 16 are available. The remainder stays in buffer for the next call.

Level two is chaining. After each block is decrypted, it is XORed byte by byte with iv. The first block's iv comes from the head of the ciphertext; later blocks use the previous ciphertext block. The source saves iv = buffer before decrypting, then swaps buffer for a fresh 16-byte array. That move is critical: if the same buffer were reused in place, the next block's decryption would overwrite it and iv would point at mutated content. With a fresh array, iv still points at the old ciphertext block and the chain stays intact.

Level three is padding removal. PDF uses PKCS#7 (the source comment says RFC 2898): the plaintext is padded to a multiple of 16, writing one byte per padding byte with that byte's value. If the plaintext is already a multiple of 16, a full extra block of 16 0x10 bytes is appended. Decryption reads the last byte psLen and checks that the final psLen bytes all equal psLen. Only if they all match is the padding considered valid; otherwise psLen is set to 0 and the data is treated as unpadded.

That last tolerance matters. Encrypted documents are wildly varied; some generators write non-conforming padding, or the stream length is simply not aligned. Throwing would make the whole page unreadable, whereas treating it as unpadded at least returns the usable data. Padding is only checked when finalize is true, because only the last block knows there is nothing after it. A middle block whose last byte happens to be 16 must not have that byte stripped as padding.

result[result.length - 1] = lastBlock.subarray(0, 16 - psLen) returns a view rather than a copy; the following output.set moves the bytes from that view into the final buffer. outputLength starts as whole blocks and then subtracts the padding length, so the final array never carries extra padding bytes.

The First Call to decryptBlock

decryptBlock does not call _decryptBlock2 directly. It first deals with "the IV before the first block":

  decryptBlock(data, finalize, iv = null) {
    const sourceLength = data.length;
    const buffer = this.buffer;
    let bufferLength = this.bufferPosition;
    // If an IV is not supplied, wait for IV values. They are at the start
    // of the stream.
    if (iv) {
      this.iv = iv;
    } else {
      for (
        let i = 0;
        bufferLength < 16 && i < sourceLength;
        ++i, ++bufferLength
      ) {
        buffer[bufferLength] = data[i];
      }
      if (bufferLength < 16) {
        // Need more data.
        this.bufferLength = bufferLength;
        return new Uint8Array(0);
      }
      this.iv = buffer;
      data = data.subarray(16);
    }
    this.buffer = new Uint8Array(16);
    this.bufferLength = 0;
    // starting decryption
    this.decryptBlock = this._decryptBlock2;
    return this.decryptBlock(data, finalize);
  }

There are two kinds of entry:

  • Explicit IV: getOwnerKey, getUserKey and Algorithm 2.B all pass an IV (usually 16 zeros or part of the key). Here this.iv = iv, and the input is ciphertext from the very first byte.
  • No IV: PDF streams and strings place the IV at the front of the ciphertext. The first call must read the first 16 bytes into the buffer as the IV; if this call supplies fewer than 16 bytes, it returns an empty array and waits for the next call to accumulate more.

Once enough bytes are collected, this.iv = buffer, data = data.subarray(16) strips the IV off the input, the buffer is reset, and finally a crucial self-replacement runs:

this.decryptBlock = this._decryptBlock2;

This creates an own property on the instance with the same name, shadowing the prototype's decryptBlock. Every later call goes straight to _decryptBlock2 and no longer repeats the "read the IV" logic. New instances still get the IV-handling version from the prototype, so each stream consumes its own header correctly. This is a lazy state machine implemented by method replacement; it saves one branch compared with checking if (!this.iv) on every call, and it cleanly separates the duties of "first call" and "later calls".

There is another detail worth noticing while reading the source: both _decryptBlock2 and decryptBlock take the initial buffer length from this.bufferPosition, but bufferPosition is only set to 0 in the constructor and never updated afterwards. What actually carries residual bytes across calls is this.buffer, yet the next call still writes from position 0. Under the existing call patterns this causes no problem: DecryptStream feeds 512-byte chunks and 512 is a multiple of 16; decryptString and the /OE, /UE paths feed the complete data in one go; and a final unaligned chunk already reaches finalize, where the residual is meant to be discarded. In other words, the code assumes "every input except the last is block aligned", and that assumption is guaranteed upstream.

CBC Encryption

The chaining order in the encryption direction is reversed: XOR with the IV first, then apply the round function; the ciphertext that comes out becomes the next block's IV.

  encrypt(data, iv) {
    const sourceLength = data.length;
    let buffer = this.buffer,
      bufferLength = this.bufferPosition;
    const result = [];
 
    iv ||= new Uint8Array(16);
    for (let i = 0; i < sourceLength; ++i) {
      buffer[bufferLength] = data[i];
      ++bufferLength;
      if (bufferLength < 16) {
        continue;
      }
 
      for (let j = 0; j < 16; ++j) {
        buffer[j] ^= iv[j];
      }
 
      // buffer is full, encrypting
      const cipher = this._encrypt(buffer, this._key);
      iv = cipher;
      result.push(cipher);
      buffer = new Uint8Array(16);
      bufferLength = 0;
    }
    // saving incomplete buffer
    this.buffer = buffer;
    this.bufferLength = bufferLength;
    this.iv = iv;
    if (result.length === 0) {
      return new Uint8Array(0);
    }
    // combining plain text blocks into one
    const outputLength = 16 * result.length;
    const output = new Uint8Array(outputLength);
    for (let i = 0, j = 0, ii = result.length; i < ii; ++i, j += 16) {
      output.set(result[i], j);
    }
    return output;
  }

Compared with decryption there are three differences:

  1. No finalize. Encryption does not remove padding, and it does not append padding either; the caller owns padding. encryptString pads the string to a multiple of 16 first, and Algorithm 2.B in PDF20 explicitly requires no padding.
  2. No self-replacement. Every call takes an iv argument, defaulting to an all-zero IV. this.iv is saved at the end, but the next call does not read it as a default; the argument decides where chaining starts.
  3. Output length counts whole blocks only. A tail shorter than 16 bytes stays in this.buffer and produces no output. With no finalize, that tail is never flushed; the caller must guarantee block-aligned input, or the last few bytes are silently lost.
DimensiondecryptBlockencrypt
IV sourceArgument, or first 16 bytes of ciphertextArgument, all zeros by default
First callMay be an "IV collection" phase returning an empty arrayProcesses directly, no special phase
ChainingCiphertext block XOR plaintext blockPlaintext block XOR previous ciphertext block
Last blockRemoves PKCS#7 padding when finalize is trueNo padding, residual stays in the buffer
Method swapReplaced by _decryptBlock2 after the first callNo replacement
Typical callersStreams, strings, /OE, /UEencryptString, Algorithm 2.B

Design note: CBC correctness depends on "a ciphertext block must not be overwritten before it is used for decryption". The source guarantees this by allocating a fresh 16-byte array after each processed block instead of making an explicit copy of iv. The cost is one small allocation per block; the benefit is that the chaining logic and the buffering logic can share the same buffer field. A classic trade of allocation for clarity, and it is not a bottleneck on the decryption path.

AES128Cipher and AES256Cipher: Only Key Expansion Differs

Both subclass constructors have three effective lines, and the differences are concentrated in four constants:

class AES128Cipher extends AESBaseCipher {
  constructor(key) {
    super();
 
    this._cyclesOfRepetition = 10;
    this._keySize = 160; // bits
 
    this._key = this._expandKey(key);
  }
class AES256Cipher extends AESBaseCipher {
  constructor(key) {
    super();
 
    this._cyclesOfRepetition = 14;
    this._keySize = 224; // bits
 
    this._key = this._expandKey(key);
  }
ItemAES128CipherAES256Cipher
Input key length16 bytes (128 bits)32 bytes (256 bits)
Rounds1014
Expanded key length176 bytes240 bytes
_keySize160224
Words per round key4 words4 words
Words in the initial key4 words8 words
Extra transformationNoneAn extra SubWord every 32 bytes

_keySize is the byte offset of the last round key: AES-128 has 11 round keys (the initial addition plus 10 rounds), the first 10 span 160 bytes and the last starts at 160; AES-256 has 15, and the last starts at 224. The bits in the comment is historical residue; the value is used as a byte index.

AES128Cipher: 176 Bytes of Round Keys

AES-128 key expansion starts from a 16-byte key and generates 4 new bytes at a time until 176 bytes. The source uses an rcon table for the round constants:

_rcon = new Uint8Array([
  0x8d, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36, 0x6c, 0xd8, 0xab, 0x4d, 0x9a,
  0x2f, 0x5e, 0xbc, 0x63, 0xc6, 0x97, 0x35, 0x6a,
  // the remaining entries continue along AES's xtime sequence
]);

Entry 0, 0x8d, is a placeholder so that round indices line up starting at 1. The real constants begin at 0x01, double each time, and XOR with 0x1b on overflow.

  _expandKey(cipherKey) {
    const b = 176;
    const s = this._s;
    const rcon = this._rcon;
 
    const result = new Uint8Array(b);
    result.set(cipherKey);
 
    for (let j = 16, i = 1; j < b; ++i) {
      // RotWord
      let t1 = result[j - 3];
      let t2 = result[j - 2];
      let t3 = result[j - 1];
      let t4 = result[j - 4];
      // SubWord
      t1 = s[t1];
      t2 = s[t2];
      t3 = s[t3];
      t4 = s[t4];
      // Rcon
      t1 ^= rcon[i];
      for (let n = 0; n < 4; ++n) {
        result[j] = t1 ^= result[j - 16];
        j++;
        result[j] = t2 ^= result[j - 16];
        j++;
        result[j] = t3 ^= result[j - 16];
        j++;
        result[j] = t4 ^= result[j - 16];
        j++;
      }
    }
    return result;
  }

Step by step:

  1. result is 176 bytes long, with the initial key copied into the first 16.
  2. Starting at byte 16, each round takes the last 4 bytes of the previous round key (j - 4 through j - 1), loaded as t4, t1, t2, t3. The naming looks reversed; it actually follows "high byte of the 32-bit word to low byte", so t4 is the most significant and t1 the least significant. Logically, RotWord shifts [t4, t1, t2, t3] left by one byte, producing [t1, t2, t3, t4].
  3. Each of the four bytes passes through the S-box: that is SubWord.
  4. The least significant byte t1 is XORed with the round constant rcon[i].
  5. The inner loop runs four times, writing one byte per step and XORing it with the byte 16 positions earlier. After being written, t1 is reassigned the written value, so the following bytes of the same word continue to chain against the value just written. That is exactly the AES key expansion recurrence: the new word equals the old word XOR the word 16 bytes earlier, advancing word by word.

The loop variable j is modified by both the inner and outer loops, while i increments only at the end of each outer iteration. The condition j < b is advanced to 176 by the inner loop. When it finishes, _key holds 11 round keys.

AES256Cipher: 240 Bytes of Round Keys

AES-256 key expansion has one extra step: the initial key is 32 bytes (8 words), and after the eighth word, every four generated words require an extra SubWord on the preceding word, with no RotWord and no round constant. The source detects this with j % 32:

  _expandKey(cipherKey) {
    const b = 240;
    const s = this._s;
 
    const result = new Uint8Array(b);
    result.set(cipherKey);
 
    let r = 1;
    let t1, t2, t3, t4;
    for (let j = 32, i = 1; j < b; ++i) {
      if (j % 32 === 16) {
        t1 = s[t1];
        t2 = s[t2];
        t3 = s[t3];
        t4 = s[t4];
      } else if (j % 32 === 0) {
        // RotWord
        t1 = result[j - 3];
        t2 = result[j - 2];
        t3 = result[j - 1];
        t4 = result[j - 4];
        // SubWord
        t1 = s[t1];
        t2 = s[t2];
        t3 = s[t3];
        t4 = s[t4];
        // Rcon
        t1 ^= r;
        if ((r <<= 1) >= 256) {
          r = (r ^ 0x1b) & 0xff;
        }
      }
 
      for (let n = 0; n < 4; ++n) {
        result[j] = t1 ^= result[j - 32];
        j++;
        result[j] = t2 ^= result[j - 32];
        j++;
        result[j] = t3 ^= result[j - 32];
        j++;
        result[j] = t4 ^= result[j - 32];
        j++;
      }
    }
    return result;
  }

The three branches match three cases:

  • When j % 32 === 0, a new round key starts: take the last 4 bytes of the previous key, apply RotWord and SubWord, XOR with the round constant r, then double r (XOR with 0x1b on overflow).
  • When j % 32 === 16, this is the start of the second half of an initial 8-word key block: only SubWord is applied, reusing t1 through t4 left over from the previous loop iteration.
  • Elsewhere, t1 through t4 are left alone and the inner loop goes straight to the XOR advance.

The inner loop XORs at a distance of 32 bytes rather than AES-128's 16, because an AES-256 round key is 8 words, 32 bytes. t1 through t4 keep being reassigned after each write, so the chaining within a word works exactly as in AES-128.

r is updated by XORing with 0x1b once (r <<= 1) >= 256. Here r is not an 8-bit unsigned type; shifting first, testing, and finally masking with 0xff is equivalent to performing 8-bit xtime.

How the Two Subclasses Divide the Work

Placed inside the base class framework, the responsibilities of the two classes are clear:

ClassProvidesDoes not provide
AESBaseCipherFour tables, _encrypt, _decrypt, CBC buffering, padding, IV logicRound count, round keys, key length
AES128Cipher10 rounds, 176 bytes of round keys, the rcon tableRound functions, CBC
AES256Cipher14 rounds, 240 bytes of round keys, the extra SubWord stepRound functions, CBC

This also explains why PDF20 can reuse AES128Cipher directly: Algorithm 2.B needs only "encrypt some data with a 16-byte key, a specified IV and no padding", which is exactly what AES128Cipher.encrypt offers. It does not need to know where the key came from, nor does it need object numbers.

Design note: AES-128 and AES-256 are two parameterisations in cryptography, and in this code they are one base class plus two key expansions. Separating "round function" from "key schedule" avoids duplicating the 10-round and 14-round loops, and also lets PDF20 treat AES-128 as a pure "CBC encryption primitive" without caring about PDF's encryption dictionary.

Password Validation: PDFBase, PDF17 and PDF20

The three password algorithm classes serve one purpose: decide whether "the characters the user typed" are the password protected by /O or /U, and hand over the file key when the answer is yes. They take no part in object-level encryption and know nothing about /CF or /StmF. This part is therefore purely functional: the inputs are password bytes, salts and validation values, and the output is a boolean or a 32-byte key.

PDFBase: Two Checks, Two Key Unwraps

The base class defines four public methods and leaves _hash to the subclasses:

class PDFBase {
  constructor() {
    if (
      (typeof PDFJSDev === 'undefined' || PDFJSDev.test('TESTING')) &&
      this.constructor === PDFBase
    ) {
      unreachable('Cannot initialize PDFBase.');
    }
  }
 
  _hash(password, input, userBytes) {
    unreachable('Abstract method `_hash` called');
  }
 
  checkOwnerPassword(password, ownerValidationSalt, userBytes, ownerPassword) {
    const hashData = new Uint8Array(password.length + 56);
    hashData.set(password, 0);
    hashData.set(ownerValidationSalt, password.length);
    hashData.set(userBytes, password.length + ownerValidationSalt.length);
    const result = this._hash(password, hashData, userBytes);
    return isArrayEqual(result, ownerPassword);
  }
 
  checkUserPassword(password, userValidationSalt, userPassword) {
    const hashData = new Uint8Array(password.length + 8);
    hashData.set(password, 0);
    hashData.set(userValidationSalt, password.length);
    const result = this._hash(password, hashData, []);
    return isArrayEqual(result, userPassword);
  }
 
  getOwnerKey(password, ownerKeySalt, userBytes, ownerEncryption) {
    const hashData = new Uint8Array(password.length + 56);
    hashData.set(password, 0);
    hashData.set(ownerKeySalt, password.length);
    hashData.set(userBytes, password.length + ownerKeySalt.length);
    const key = this._hash(password, hashData, userBytes);
    const cipher = new AES256Cipher(key);
    return cipher.decryptBlock(ownerEncryption, false, new Uint8Array(16));
  }
 
  getUserKey(password, userKeySalt, userEncryption) {
    const hashData = new Uint8Array(password.length + 8);
    hashData.set(password, 0);
    hashData.set(userKeySalt, password.length);
    // `key` is the decryption key for the UE string.
    const key = this._hash(password, hashData, []);
    const cipher = new AES256Cipher(key);
    return cipher.decryptBlock(userEncryption, false, new Uint8Array(16));
  }
}

The two check methods differ in two places:

MethodSalt lengthExtra inputTotal hash input lengthCompared against
checkUserPassword8 bytesNonePassword length plus 8First 32 bytes of /U
checkOwnerPassword8 bytesFirst 48 bytes of /UPassword length plus 56First 32 bytes of /O

User validation passes only "password plus user validation salt" to _hash. Owner validation hashes "password plus owner validation salt plus the first 48 bytes of /U" together. Why include /U? Because the owner password's validation value must be bound to the user password: if someone changes /U without changing /O, owner validation fails. The two passwords therefore form a chain and cannot be forged independently.

isArrayEqual performs a strict byte-by-byte comparison and returns false immediately on a length mismatch. Nothing like "hash first, then compare strings" is used, because _hash already returns a byte array and the comparison is over fixed-length results.

The two key-unwrap methods have the same input structure, except that the salt is the "key salt", and the hash result is used directly as the AES-256 key to decrypt /OE or /UE:

const cipher = new AES256Cipher(key);
return cipher.decryptBlock(ownerEncryption, false, new Uint8Array(16));

The third argument of decryptBlock is a 16-byte all-zero IV, and the second argument false means no padding removal. Both /OE and /UE are 32-byte ciphertexts, exactly two AES blocks, so no padding is needed and no bytes should be removed. The 32 bytes that come out are the file key.

The layout of /O and /U in R 5 and R 6 is fixed:

OffsetLengthMeaning in /OMeaning in /U
0-3132Owner password validation valueUser password validation value
32-398Owner validation saltUser validation salt
40-478Owner key saltUser key salt
After 48VariableUsually nothingOwner validation takes the first 48 bytes

This explains why checkOwnerPassword needs userBytes: it takes the first 48 bytes of /U, which cover the user validation value and both user salts, but nothing after the user key salt. Owner validation therefore checks not only "you know the owner password" but also "this /U has not been swapped".

PDF17: A Single SHA-256

PDF17 corresponds to R 5, and _hash is one line:

class PDF17 extends PDFBase {
  _hash(password, input, userBytes) {
    return calculateSHA256(input, 0, input.length);
  }
}

It takes three parameters but uses only input. password and userBytes stay in the signature because the parent's four methods all call it with a uniform shape; PDF17 does not need to reassemble them inside a hash loop, because input is already the fully assembled data.

calculateSHA256 returns 32 bytes. Validation compares them against the first 32 bytes of /O or /U; key unwrapping uses those same 32 bytes directly as the AES-256 key. Note that there is no iteration here: R 5 gets its strength from SHA-256 and the salts, not from repeated hashing. R 6 is what introduces iteration, to raise the cost of offline brute force.

PDF20: Algorithm 2.B

PDF20 corresponds to R 6 and is the most complex algorithm in the file:

class PDF20 extends PDFBase {
  _hash(password, input, userBytes) {
    // This refers to Algorithm 2.B as defined in ISO 32000-2.
    let k = calculateSHA256(input, 0, input.length).subarray(0, 32);
    let e = [0];
    let i = 0;
    while (i < 64 || e.at(-1) > i - 32) {
      const combinedLength = password.length + k.length + userBytes.length,
        combinedArray = new Uint8Array(combinedLength);
      let writeOffset = 0;
      combinedArray.set(password, writeOffset);
      writeOffset += password.length;
      combinedArray.set(k, writeOffset);
      writeOffset += k.length;
      combinedArray.set(userBytes, writeOffset);
 
      const k1 = new Uint8Array(combinedLength * 64);
      for (let j = 0, pos = 0; j < 64; j++, pos += combinedLength) {
        k1.set(combinedArray, pos);
      }
      // AES128 CBC NO PADDING with first 16 bytes of k as the key
      // and the second 16 as the iv.
      const cipher = new AES128Cipher(k.subarray(0, 16));
      e = cipher.encrypt(k1, k.subarray(16, 32));
      // Now we have to take the first 16 bytes of an unsigned big endian
      // integer and compute the remainder modulo 3. That is a fairly large
      // number and JavaScript isn't going to handle that well.
      // The number is e0 + 256 * e1 + 256^2 * e2... and 256 % 3 === 1, hence
      // the powers of 256 are === 1 modulo 3 and finally the number modulo 3
      // is equal to the remainder modulo 3 of the sum of the e_n.
      const remainder = Math.sumPrecise(e.slice(0, 16)) % 3;
      if (remainder === 0) {
        k = calculateSHA256(e, 0, e.length);
      } else if (remainder === 1) {
        k = calculateSHA384(e, 0, e.length);
      } else if (remainder === 2) {
        k = calculateSHA512(e, 0, e.length);
      }
      i++;
    }
    return k.subarray(0, 32);
  }
}

Breaking it down:

Step one, initialisation. Hash input once with SHA-256 and take the first 32 bytes as k. input is the parent's assembled "password plus salt" or "password plus salt plus first 48 bytes of /U". e starts as [0], purely a placeholder so that the loop condition has something to read from e.at(-1).

Step two, the loop condition. while (i < 64 || e.at(-1) > i - 32) means two things:

  • The first 64 rounds run unconditionally. i starts at 0, so the loop runs at least 64 times.
  • After 64 rounds, look at the last byte of the previous round's output e. As long as it is greater than i - 32, keep going. This condition comes from the specification and turns the iteration count into a value determined by intermediate results: most documents stop around 64 to 70 rounds, occasionally longer. It is neither a fixed count nor an infinite loop.

Step three, assemble the data to encrypt. combinedArray is the concatenation of the password bytes, the current k, and userBytes. userBytes is an empty array for user validation and the first 48 bytes of /U for owner validation. That array is then repeated 64 times into k1, with a length of combinedLength * 64. Repeating 64 times is required by the specification, to stretch the input far beyond a single AES block.

Step four, AES-128-CBC encryption. The key is the first 16 bytes of k, and the IV is the next 16 bytes of k (which is 32 bytes in total). encrypt does no padding, so the output contains only complete blocks. This reuses the AES128Cipher described earlier; no second AES implementation is written.

Step five, use the result to pick the next hash. Treat the first 16 bytes of e as a big-endian unsigned integer and take it modulo 3. That number can be 128 bits, which a JavaScript Number cannot hold. The source uses a number-theoretic trick:

  • The large integer equals e0 + 256·e1 + 256^2·e2 + ....
  • Since 256 % 3 === 1, every power of 256 is congruent to 1 modulo 3.
  • Therefore the whole number modulo 3 equals the sum of all bytes modulo 3.

Math.sumPrecise(e.slice(0, 16)) sums the bytes, then % 3 is applied. Math.sumPrecise is a newer exact-summation API, used to avoid floating-point round-off in extreme cases. A remainder of 0 means SHA-256, 1 means SHA-384, 2 means SHA-512. All three paths produce at least 32 bytes, and the next round takes the first 32 as the new k.

Step six, finish. After the loop, return the first 32 bytes of k. That value is the validation value and also the input for deriving the /OE and /UE decryption keys.

The differences between PDF20 and PDF17 as a table:

ItemPDF17 (R 5)PDF20 (R 6)
Initial hashSHA-256SHA-256, first 32 bytes
IterationNoneAt least 64 rounds, last byte decides whether to continue
Per-round cipherNoneAES-128-CBC with key and IV both taken from current k
Per-round hash choiceFixed SHA-256SHA-256/384/512 chosen by the first 16 bytes summed and taken modulo 3
Output32 bytesFirst 32 bytes
Typical costOne hashTens of AES and hash rounds, noticeably slower

The slowness is a design goal, not an implementation defect. R 6 iteration makes every password guess pay for tens of AES and hash rounds, which raises the cost of an offline attack.

Password Encoding for R5 and R6

Passwords are not hashed as JavaScript strings directly. Different revisions specify different ways of turning a password into bytes, and the factory has three corresponding branches:

RevisionPreprocessingByte conversionSource location
R 2, R 3, R 4NonestringToBytes, low 8 bits per characterThe else branch in the constructor
R 5Noneutf8PasswordToBytesThe algorithm === 5 branch
R 6saslPrep, then UTF-8utf8PasswordToBytesThe revision === 6 branch

R 2 through R 4 are older revisions, and the specification treats the password as a byte string. PDF.js uses stringToBytes, which takes charCodeAt(i) & 0xff per character, that is, truncates to the low 8 bits. That is fine for ASCII passwords; for non-ASCII passwords it relies on whatever encoding convention the producer assumed.

R 5 and R 6 switch to UTF-8. R 6 adds SASLprep: RFC 4013 Unicode normalisation, whitespace folding and prohibited-character checks, which normalise passwords that "look the same but have different code points". It is provided by sasl_prep.js and not implemented in crypto.js.

Design note: Password validation is not "decrypt /O and compare" but "recompute the validation value from the candidate password and compare it with /O and /U". Validation values are irreversible hashes, so the document never contains a recoverable password. At the same time, in R 5 and R 6 the validation values and key salts are separate, so a user can change the password without re-encrypting the whole document; only /OE and /UE need repacking.

utf8PasswordToBytes: Turning a Password into Bytes

utf8PasswordToBytes is eight lines, yet it is the password entry point for R 5 and R 6:

function utf8PasswordToBytes(password) {
  try {
    password = utf8StringToString(password);
  } catch {
    warn('CipherTransformFactory: Unable to convert UTF8 encoded password.');
  }
  return stringToBytes(password);
}

utf8StringToString comes from shared/util.js and is implemented as:

function utf8StringToString(str) {
  return unescape(encodeURIComponent(str));
}

encodeURIComponent turns non-ASCII characters in a JavaScript string into percent-encoded UTF-8 sequences, and unescape turns the percent encoding back into a binary string with one character per byte. Together they convert a UTF-16 string into a "UTF-8 byte string" whose character code points all fall between 0 and 255. Finally stringToBytes takes the low 8 bits of each character to produce the actual Uint8Array.

Why the try wrapper? encodeURIComponent throws a URIError on a lone surrogate. Half a surrogate pair in user input is not rare; some input methods or copy-paste paths leave fragments behind. The document must not become unopenable because of that, so the source logs a warning and passes the original string to stringToBytes, preserving a best-effort attempt.

The factory adds one more fallback for R 6: if the SASLprep output differs from the original string, it converts the original string to bytes as well, yielding a second candidate. That honours the specification while staying compatible with documents whose producers did not apply SASLprep. The logic lives in the constructor:

if (revision === 6) {
  const preppedPassword = saslPrep(password);
  passwordBytes = utf8PasswordToBytes(preppedPassword);
  if (preppedPassword !== password) {
    rawPasswordBytes = utf8PasswordToBytes(password);
  }
}

rawPasswordBytes exists only when preprocessing actually changed the string. Validation tries both candidates in order and stops at the first success. That is compatibility first: follow the specification path, then fall back to the raw input.

Design note: A password's "characters" and "bytes" are not the same thing in PDF. R 2 through R 4 treat the password as a byte string, while R 5 and R 6 treat it as UTF-8. utf8PasswordToBytes is the only conversion point on that boundary, so all encoding differences are collected here and the rest of the factory only sees byte arrays.

CipherTransform: Per-Object Transform Facade

Once the factory has computed the file key, the real encryption entry point is CipherTransform. It holds no key, only a "resolver": given a crypt filter name, return a cipher constructor:

class CipherTransform {
  /** @type {Map<string, CipherConstructors>} */
  #cipherCache = new Map();
 
  /** @type {Name | null} */
  embeddedFilterName = null;
 
  constructor(resolveCipher, stringFilterName = null, streamFilterName = null) {
    this.resolveCipher = resolveCipher;
    this.streamFilterName = streamFilterName;
    this.stringFilterName = stringFilterName;
  }
 
  #getCipher(filterName = null) {
    const key = filterName instanceof Name ? filterName.name : "__default__";
 
    return this.#cipherCache.getOrInsertComputed(key, () =>
      this.resolveCipher(filterName)
    );
  }

resolveCipher is a closure the factory creates on the spot inside createCipherTransform. It captures this (the factory), the object number num and the generation gen, so "which object key is used" is completely invisible to the transform object. CipherTransform handles only three things: cache the filter-to-constructor mapping, pick the default filter for streams and strings, and make sure every encryption or decryption gets a new cipher instance.

The cache key in #getCipher has a special value, __default__. When filterName is not a Name (usually null), that key is used instead of the empty string, so the default filter never collides with a filter that happens to have an empty name. getOrInsertComputed is a newer Map method: the callback runs and its result is stored only when the key is absent, which is semantically "look up, and create if missing".

It caches constructors, not instances. createStream, decryptString and encryptString all call new every time. A constructor is stateless; only instances carry state. Caching constructors avoids repeated filter resolution while preventing two streams from sharing one RC4 permutation table or one AES IV.

createStream: Streaming Decryption

  createStream(stream, length, cryptFilterName = null) {
    const defaultFilterName =
      this.embeddedFilterName && isDict(stream.dict, "EmbeddedFile")
        ? this.embeddedFilterName
        : this.streamFilterName;
    const Cipher = this.#getCipher(cryptFilterName || defaultFilterName);
    const cipher = new Cipher();
    return new DecryptStream(
      stream,
      length,
      function cipherTransformDecryptStream(data, finalize) {
        return cipher.decryptBlock(data, finalize);
      }
    );
  }

Default filter selection follows a priority order:

  1. If the caller passed cryptFilterName explicitly (for example the /Name inside a /Crypt filter's /DecodeParms), use it directly.
  2. Otherwise, if this is an embedded file stream and /EFF exists, use /EFF.
  3. Otherwise use /StmF.

isDict(stream.dict, "EmbeddedFile") checks the /Type of the stream dictionary. Only embedded files are allowed to use /EFF, because /EFF exists specifically as a separate filter for attachments. An ordinary content stream must not misuse it even when embeddedFilterName is present.

The returned DecryptStream receives a closure that captures the freshly created cipher. On every block read, DecryptStream calls decryptBlock(data, finalize); the cipher instance's state advances with the blocks, while the closure itself does not care how far it has progressed. The stream's lifetime and the cipher instance's lifetime are bound one-to-one here.

decryptString: Decrypting in One Pass

  decryptString(s) {
    const Cipher = this.#getCipher(this.stringFilterName);
    const cipher = new Cipher();
    let data = stringToBytes(s);
    data = cipher.decryptBlock(data, true);
    return bytesToString(data);
  }

A string has no need for streaming: it is already in memory and usually short. So the whole string is converted to bytes at once, decryptBlock is called with finalize set to true, and the result is converted back. For AES that means processing all blocks at once and removing the trailing padding; for RC4 the true is ignored. Finally bytesToString turns the byte array back into a PDF byte string.

Note that s is a string, not a Uint8Array. PDF string objects are kept by the parser in the "one byte per character" form, so stringToBytes is safe; a code point above 255 would mean something upstream is already broken.

encryptString: Padding and IV When Writing Back

  encryptString(s) {
    const Cipher = this.#getCipher(this.stringFilterName);
    const cipher = new Cipher();
    if (cipher instanceof AESBaseCipher) {
      // Append some chars equal to "16 - (M mod 16)"
      // where M is the string length (see section 7.6.2 in PDF specification)
      // to have a final string where the length is a multiple of 16.
      // Special note:
      //   "Note that the pad is present when M is evenly divisible by 16;
      //   it contains 16 bytes of 0x10."
      const strLen = s.length;
      const pad = 16 - (strLen % 16);
      s += String.fromCharCode(pad).repeat(pad);
 
      // Generate an initialization vector
      const iv = new Uint8Array(16);
      crypto.getRandomValues(iv);
 
      let data = stringToBytes(s);
      data = cipher.encrypt(data, iv);
 
      const buf = new Uint8Array(16 + data.length);
      buf.set(iv);
      buf.set(data, 16);
 
      return bytesToString(buf);
    }
 
    let data = stringToBytes(s);
    data = cipher.encrypt(data);
    return bytesToString(data);
  }

The AES branch does three things:

  1. Pad. pad = 16 - (strLen % 16), appending pad bytes each with the value pad. When the length is already a multiple of 16, pad equals 16 and a full block of 0x10 is appended, exactly as the specification requires. strLen is a JavaScript string length, which is also the byte count, because every character in a PDF string occupies one byte.
  2. Generate an IV. crypto.getRandomValues(iv) produces 16 bytes from the browser's cryptographic random source. A new IV per encryption means the same string encrypted twice gives different ciphertext; that is required by CBC semantics and avoids leaking "same plaintext, same ciphertext".
  3. Prefix the IV. The 16-byte IV is prepended to the ciphertext and the whole thing is converted to a string. The reader's first decryptBlock call consumes exactly that prefix as the IV, closing the loop between writer and reader.

The RC4 branch has no padding, no IV and no prefix: cipher.encrypt(data) just XORs and the length matches the input. That is also why encryptString branches on instanceof AESBaseCipher: the two ciphers have different agreements about what a string looks like on the wire.

OperationEntry pointCipher instanceFilter sourceLast block / padding
Read streamcreateStreamNew per streamExplicit name, /EFF, /StmFDecryptStream passes finalize
Read stringdecryptStringNew per call/StrFAlways true
Write stringencryptStringNew per call/StrFAES pads and prefixes a random IV
Write stream (bytes)encryptStringNew per call/StrFSame as strings

Design note: CipherTransform fully separates "choosing the algorithm" from "using the algorithm". It does not inspect /V, does not derive keys, and does not know object numbers; all of that happens in the factory closure. The transform object can therefore be cached and reused by many strings, while the real cipher state is always one-shot.

CipherTransformFactory: Turning the Dictionary into Usable Keys

The factory is the largest class in the file: the constructor is nearly two hundred lines long and there are four private methods. Its job reduces to four steps: validate the dictionary, split out the validation values and salts, derive the file key from the password, and package "choose the algorithm by object number" into a closure.

Three Gates: Filter, V and Length

The constructor opens with a series of fast failures:

  constructor(dict, fileId, password) {
    const filter = dict.get("Filter");
    if (!isName(filter, "Standard")) {
      throw new FormatError("unknown encryption method");
    }
    this.filterName = filter.name;
    this.dict = dict;
    this.#fileId = fileId;
    const algorithm = dict.get("V");
    if (
      !Number.isInteger(algorithm) ||
      (algorithm !== 1 && algorithm !== 2 && algorithm !== 4 && algorithm !== 5)
    ) {
      throw new FormatError("unsupported encryption algorithm");
    }
    this.algorithm = algorithm;

The three gates map onto three failures:

CheckFailure conditionError thrown
/FilterNot a Name, or the name is not StandardFormatError: unknown encryption method
/VNot an integer, or not one of 1, 2, 4, 5FormatError: unsupported encryption algorithm
/LengthNot an integer, or below 40, or not a multiple of 8FormatError: invalid key length

/Filter supports only Standard because this file implements the standard security handler. Other handlers, such as those used by some third-party plug-ins, need entirely different algorithms, and PDF.js does not pretend to support them here. /V 3 is excluded, as noted earlier.

Recovering Length

/Length is one of the keys most often missing or written incorrectly. The source tries to recover it when absent:

let keyLength = dict.get('Length');
if (!keyLength) {
  // Spec asks to rely on encryption dictionary's Length entry, however
  // some PDFs don't have it. Trying to recover.
  if (algorithm <= 3) {
    // For 1 and 2 it's fixed to 40-bit, for 3 40-bit is a minimal value.
    keyLength = 40;
  } else {
    // Trying to find default handler -- it usually has Length.
    const cfDict = dict.get('CF');
    const streamCryptoName = dict.get('StmF');
    if (cfDict instanceof Dict && streamCryptoName instanceof Name) {
      cfDict.suppressEncryption = true; // See comment below.
      const handlerDict = cfDict.get(streamCryptoName.name);
      keyLength = handlerDict?.get('Length') || 128;
      if (keyLength < 40) {
        // Sometimes it's incorrect value of bits, generators specify
        // bytes.
        keyLength <<= 3;
      }
    }
  }
}
if (!Number.isInteger(keyLength) || keyLength < 40 || keyLength % 8 !== 0) {
  throw new FormatError('invalid key length');
}

There are three recovery rules:

  • V 1 and V 2 are fixed at 40 bits; V 3 also has a 40-bit minimum, so all three fall back to 40.
  • V 4 and V 5 look up the Length of the handler that /StmF points at inside /CF, and fall back to 128 when it is absent.
  • If the value found is below 40, the producer wrote bytes into a field measured in bits, so it is shifted left 3 bits to become bits.

suppressEncryption = true appears here again for the same reason as on the trailer's /Encrypt: /CF may be an indirect object, and reading it must not trigger decryption. handlerDict may also be a reference, so resolving it goes through XRef.fetch; without the flag this would recurse.

Crypt Filters and Three Default Names

From V 4 onwards, streams and strings can each choose their own crypt filter. The source gives the three names defaults:

let cf = null;
let stmf = Name.get('Identity');
let strf = Name.get('Identity');
let eff = stmf;
 
if (algorithm >= 4) {
  cf = dict.get('CF');
  if (cf instanceof Dict) {
    // The 'CF' dictionary itself should not be encrypted, and by setting
    // `suppressEncryption` we can prevent an infinite loop inside of
    // `XRef_fetchUncompressed` if the dictionary contains indirect
    // objects (fixes issue7665.pdf).
    cf.suppressEncryption = true;
  }
  stmf = dict.get('StmF') || Name.get('Identity');
  strf = dict.get('StrF') || Name.get('Identity');
  eff = dict.get('EFF') || stmf;
}
this.cf = cf;
this.stmf = stmf;
this.strf = strf;
this.eff = eff;

The default is Identity, not None. In PDF crypt filter semantics, Identity means "this segment is not encrypted"; when resolveCipher cannot find a matching CFM, it returns NullCipher. The two end up with the same effect but arrive by different routes: one is a naming convention, the other is a fallback for a missing dictionary entry.

/EFF defaults to /StmF, not to Identity. If a document does not declare a separate filter for embedded files, attachment streams are encrypted together with ordinary streams. Only an explicit /EFF Identity leaves attachments unencrypted.

Splitting Validation Values and Salts from O and U

const ownerBytes = stringToBytes(dict.get('O')),
  userBytes = stringToBytes(dict.get('U'));
// prepare keys
const ownerPassword = ownerBytes.subarray(0, 32);
const userPassword = userBytes.subarray(0, 32);
const flags = dict.get('P');
const revision = dict.get('R');
// meaningful when V is 4 or 5
const encryptMetadata =
  (algorithm === 4 || algorithm === 5) && dict.get('EncryptMetadata') !== false;
this.encryptMetadata = encryptMetadata;
 
const fileIdBytes = stringToBytes(fileId);

ownerPassword and userPassword are views over the first 32 bytes, not copies. The later subarray(32, 40) and subarray(40, 48) are views as well, so on the R 5 and R 6 paths the constructor never reallocates /O or /U. This relies on Uint8Array.subarray semantics: share the underlying buffer, change only the view range.

revision is not validated as an integer. dict.get("R") may return undefined, the comparisons that follow fall into the else branch, and #prepareKeyData then treats it as R 2. That is a "open it if at all possible" strategy: documents without /R are rare, but when one appears, trying the oldest revision is better than rejecting it outright.

encryptMetadata is meaningful only for V 4 and V 5. Its default is true and only an explicit false turns it off. The boolean feeds into the hash input: from R 4 onwards, if metadata is not encrypted, four 0xff bytes are appended to the hash. It also affects whether Catalog suppresses decryption when reading metadata streams.

Password Preprocessing and Two Derivation Paths

The last part of the constructor branches on algorithm:

let passwordBytes, rawPasswordBytes;
if (password) {
  if (revision === 6) {
    const preppedPassword = saslPrep(password);
    passwordBytes = utf8PasswordToBytes(preppedPassword);
    if (preppedPassword !== password) {
      rawPasswordBytes = utf8PasswordToBytes(password);
    }
  } else if (algorithm === 5) {
    passwordBytes = utf8PasswordToBytes(password);
  } else {
    passwordBytes = stringToBytes(password);
  }
}

These three branches correspond to the three password encodings listed earlier. The order matters: revision === 6 is checked before algorithm === 5. R 6 always belongs to V 5, so testing V 5 first would skip SASLprep for R 6. "Special case first, general case after" is a very common branch order in compatibility code.

Then comes:

let encryptionKey;
if (algorithm !== 5) {
  encryptionKey = this.#prepareKeyData(
    fileIdBytes,
    passwordBytes,
    ownerPassword,
    userPassword,
    flags,
    revision,
    keyLength,
    encryptMetadata,
  );
} else {
  const ownerValidationSalt = ownerBytes.subarray(32, 40);
  const ownerKeySalt = ownerBytes.subarray(40, 48);
  const uBytes = userBytes.subarray(0, 48);
  const userValidationSalt = userBytes.subarray(32, 40);
  const userKeySalt = userBytes.subarray(40, 48);
  const ownerEncryption = stringToBytes(dict.get('OE'));
  const userEncryption = stringToBytes(dict.get('UE'));
  const perms = stringToBytes(dict.get('Perms'));
  for (const candidate of rawPasswordBytes ? [passwordBytes, rawPasswordBytes] : [passwordBytes]) {
    encryptionKey = this.#createEncryptionKey20(
      revision,
      candidate,
      ownerPassword,
      ownerValidationSalt,
      ownerKeySalt,
      uBytes,
      userPassword,
      userValidationSalt,
      userKeySalt,
      ownerEncryption,
      userEncryption,
      perms,
    );
    if (encryptionKey) {
      break;
    }
  }
}

V 1, V 2 and V 4 go through #prepareKeyData. They share one shape: hash the password, /O, /U, /P and the file identifier, derive the file key, then validate /U with the derived result. V 5 takes a completely different route: instead of deriving the file key from /O and /U directly, it first computes a "user key" or "owner key" from the salts and the password, then uses that key to decrypt /UE or /OE.

On the V 5 path all salts are slices of /O and /U; only /OE, /UE and /Perms are separate strings. perms is passed into #createEncryptionKey20, but that version of the method does not use it after receiving it: validation of the encrypted permissions copy is not implemented in this version. Do not go looking for a use site when reading the source.

#prepareKeyData: Key Derivation for R2 through R4

This path first assembles "password, /O, /P, file identifier" into one hash input:

const hashDataSize = 40 + ownerPassword.length + fileId.length;
const hashData = new Uint8Array(hashDataSize);
let i = 0,
  j,
  n;
if (password) {
  n = Math.min(32, password.length);
  for (; i < n; ++i) {
    hashData[i] = password[i];
  }
}
j = 0;
while (i < 32) {
  hashData[i++] = CipherTransformFactory._defaultPasswordBytes[j++];
}
// as now the padded password in the hashData[0..i]
hashData.set(ownerPassword, i);
i += ownerPassword.length;
hashData[i++] = flags & 0xff;
hashData[i++] = (flags >> 8) & 0xff;
hashData[i++] = (flags >> 16) & 0xff;
hashData[i++] = (flags >>> 24) & 0xff;
hashData.set(fileId, i);
i += fileId.length;
if (revision >= 4 && !encryptMetadata) {
  hashData.fill(0xff, i, i + 4);
  i += 4;
}
let hash = calculateMD5(hashData, 0, i);
const keyLengthInBytes = keyLength >> 3;
if (revision >= 3) {
  for (j = 0; j < 50; ++j) {
    hash = calculateMD5(hash, 0, keyLengthInBytes);
  }
}
const encryptionKey = hash.subarray(0, keyLengthInBytes);

The segments of the hash input are fixed:

OrderContentLengthNotes
1Password bytesUp to 32 bytesPadded with defaults when shorter
2First 32 bytes of /O32 bytesOwner validation value
3/P4 bytes, little-endianPermission bits
4File identifierSame length as /ID[0]File-level salt
5Four 0xff bytes4 bytes, optionalAppended from R 4 when metadata is unencrypted

When the password is shorter than 32 bytes it is padded with _defaultPasswordBytes, a 32-byte constant defined by the specification. /O follows the padded password, so the first 64 bytes always mean "32 bytes of padded password plus 32 bytes of owner value". That is also why hashDataSize is written as 40 + ownerPassword.length + fileId.length: 40 equals the 32-byte password area plus the 4-byte permissions area, plus 4 bytes of allowance for the metadata flag.

R 2 runs MD5 once; from R 3 the MD5 result is hashed fifty more times, taking only the first keyLengthInBytes bytes each time. The final key is the first keyLengthInBytes bytes. The iteration is not there to "store the password more safely"; it makes every brute-force guess more expensive.

After the key is computed it still has to be validated. From R 3 the check value is produced as follows:

  1. Build a block from the default padding and the file identifier, and hash it with MD5.
  2. Encrypt that MD5 output with RC4 using the file key.
  3. Derive 19 more keys with key[i] ^ j (j from 1 to 19) and encrypt the previous result with each in turn.
  4. Compare the final result byte by byte with the first 32 bytes of /U.

R 2 instead encrypts the default padding directly with RC4 under the file key and compares it with /U. A successful comparison returns the file key; a failure returns null. There is no separate "compare" method; checkData.every((data, k) => userPassword[k] === data) is the whole of it.

#decodeUserPassword: Treating the Owner Password as a User Password

When the first derivation fails, the factory assumes the user typed the owner password and first uses it to recover the user password:

let cipher, userPassword;
if (revision >= 3) {
  userPassword = ownerPassword;
  const derivedKey = new Uint8Array(keyLengthInBytes);
  for (j = 19; j >= 0; j--) {
    for (let k = 0; k < keyLengthInBytes; ++k) {
      derivedKey[k] = hash[k] ^ j;
    }
    cipher = new ARCFourCipher(derivedKey);
    userPassword = cipher.encryptBlock(userPassword);
  }
} else {
  cipher = new ARCFourCipher(hash.subarray(0, keyLengthInBytes));
  userPassword = cipher.encryptBlock(ownerPassword);
}
return userPassword;

Before this code, the method pads the password to 32 bytes and hashes it once with MD5; from R 3 it then iterates the result fifty times. The decryption order from R 3 is exactly the reverse of the check process in #prepareKeyData: the check goes j from 1 to 19, decryption goes j from 19 to 0. Because RC4 is its own inverse, applying the same derived keys backwards recovers the encrypted 32-byte user password. R 2 has only one round and uses the first few hash bytes directly as the key.

One subtlety: the fifty iterations here feed the full 16 bytes to MD5 each time, while the fifty iterations in #prepareKeyData take only the first keyLengthInBytes bytes. The two algorithms differ in how many bytes they retain, so the source does not merge them and writes each one out. Do not read either as a typo.

#createEncryptionKey20: Validation for R5 and R6

This path does not "derive the file key first, then validate"; it "validate the password first, then use the result to unwrap the file key":

const pdfAlgorithm = revision === 6 ? new PDF20() : new PDF17();
 
if (pdfAlgorithm.checkUserPassword(password, userValidationSalt, userPassword)) {
  return pdfAlgorithm.getUserKey(password, userKeySalt, userEncryption);
} else if (
  password.length &&
  pdfAlgorithm.checkOwnerPassword(password, ownerValidationSalt, uBytes, ownerPassword)
) {
  return pdfAlgorithm.getOwnerKey(password, ownerKeySalt, uBytes, ownerEncryption);
}
 
return null;

The method also truncates at the start: passwords keep at most 127 bytes and the rest is discarded; an empty password is normalised to an empty array so that later .length access cannot throw. 127 is the specification's upper bound for UTF-8 passwords.

The order is user first, owner second. A successful user password immediately unwraps /UE; otherwise, when the password is non-empty, the owner password is tried and /OE is unwrapped. Both failing returns null, and the constructor decides whether to throw "missing password" or "wrong password". revision === 6 uses PDF20; everything else (that is, R 5) uses PDF17.

Although perms is passed all the way here, it takes part in no decision. Its /Perms is the encrypted copy of the permissions, not validated in this version; the permission bits in /P remain a declaration only.

Failure Paths and Deferred Prompting

When the file key is null, the constructor handles three cases:

    if (!encryptionKey) {
      if (!password) {
        if (
          this.algorithm >= 4 &&
          isName(this.stmf, "Identity") &&
          isName(this.strf, "Identity")
        ) {
          const effCF = this.cf?.get(this.eff.name);
          const authEvent = effCF?.get("AuthEvent");
 
          if (isName(authEvent, "EFOpen")) {
            // For EFOpen with Identity as default stream/string filters, defer
            // password prompting until an EmbeddedFile stream is actually read.
            this.encryptionKey = null;
            return;
          }
        }
        throw new PasswordException(
          "No password given",
          PasswordResponses.NEED_PASSWORD
        );
      }

If no password was given, both the default stream and string filters are Identity, and the /EFF handler's /AuthEvent is EFOpen, then the document body is in fact not encrypted and only embedded files need a password when opened. In that case the constructor does not throw: it sets encryptionKey to null and returns immediately. Later, when an attachment stream is actually read, resolveCipher finds no key and throws PasswordException. The user can therefore read pages normally without being asked for a password up front just because of an attachment.

The remaining cases:

  • No password and the deferral condition not met: throw NEED_PASSWORD.
  • A password was given but both derivations failed: use #decodeUserPassword to treat it as the owner password and retry #prepareKeyData; if that also fails, throw INCORRECT_PASSWORD.
  • V 4 with a key shorter than 16 bytes: zero-extend it to 16 bytes. The source comment describes this as an undocumented compatibility fix for two problematic samples.

setPassword is the back door left for this chain. It does not modify any field of the original factory; it builds a new factory from the same dictionary and file identifier and takes the newly computed encryptionKey from it. PDFManager.updatePassword calls it, so when the user types a password in the UI later, the whole document does not need reparsing; only this key needs rebuilding.

#buildObjectKey: One Key per Object

Object keys for V 1, V 2 and V 4 are all derived here:

  #buildObjectKey(num, gen, encryptionKey, isAes = false) {
    const n = encryptionKey.length;
    const key = new Uint8Array(n + 9);
    key.set(encryptionKey);
    let i = n;
    key[i++] = num & 0xff;
    key[i++] = (num >> 8) & 0xff;
    key[i++] = (num >> 16) & 0xff;
    key[i++] = gen & 0xff;
    key[i++] = (gen >> 8) & 0xff;
    if (isAes) {
      key[i++] = 0x73;
      key[i++] = 0x41;
      key[i++] = 0x6c;
      key[i++] = 0x54;
    }
    const hash = calculateMD5(key, 0, i);
    return hash.subarray(0, Math.min(n + 5, 16));
  }

The input is "the file key, the object number as 3 little-endian bytes, the generation as 2 little-endian bytes"; for AES four more bytes sAlT (0x73 0x41 0x6c 0x54) are appended. Then MD5 is applied and the first min(n + 5, 16) bytes become the object key.

Where the three numbers come from:

  • n + 5: the file key length plus 3 bytes of object number and 2 bytes of generation. RC4 needs a key as long as its input, so nothing is truncated.
  • The 16 cap: AES keys are at most 16 bytes (AES-128). MD5 output is only 16 bytes anyway, so for V 4 n is usually 16 and min(21, 16) equals 16.
  • sAlT: using one derivation for both AES and RC4 would produce identical keys, so a fixed suffix separates the two paths. This is the domain separation required by the specification.

isAes affects only those four suffix bytes. For the same object number and the same file key, RC4 and AES derive different keys, which prevents key reuse in documents that mix both filters.

createCipherTransform: Turning Object Numbers into Algorithms

The factory's last public method chooses a cipher by filter:

  createCipherTransform(num, gen) {
    if (this.algorithm === 4 || this.algorithm === 5) {
      /** @type {ResolveCipher} */
      const resolveCipher = filterName => {
        if (!(filterName instanceof Name)) {
          throw new FormatError("Invalid crypt filter name.");
        }
        const cryptFilter = this.cf.get(filterName.name);
        const cfm = cryptFilter?.get("CFM");
 
        if (!cfm || cfm.name === "None") {
          return NullCipher;
        }
        if (!this.encryptionKey) {
          throw new PasswordException(
            "No password given",
            PasswordResponses.NEED_PASSWORD
          );
        }
        if (this.algorithm === 5 || cfm.name === "AESV3") {
          // V=5 always uses 256-bit AES with the file encryption key, even
          // when a producer wrongly sets the crypt filter's CFM to AESV2
          // (bug 2046659).
          return AES256Cipher.bind(null, this.encryptionKey);
        }
        if (cfm.name === "V2") {
          return ARCFourCipher.bind(
            null,
            this.#buildObjectKey(
              num,
              gen,
              this.encryptionKey,
              /* isAes = */ false
            )
          );
        }
        if (cfm.name === "AESV2") {
          return AES128Cipher.bind(
            null,
            this.#buildObjectKey(
              num,
              gen,
              this.encryptionKey,
              /* isAes = */ true
            )
          );
        }
        throw new FormatError("Unknown crypto method");
      };
 
      const transform = new CipherTransform(
        resolveCipher,
        this.strf,
        this.stmf
      );
      transform.embeddedFilterName = this.eff;
      return transform;
    }
 
    // algorithms 1 and 2
    /** @type {ResolveCipher} */
    const resolveCipher = () =>
      ARCFourCipher.bind(
        null,
        this.#buildObjectKey(num, gen, this.encryptionKey, /* isAes = */ false)
      );
    return new CipherTransform(resolveCipher);
  }

The decision order inside resolveCipher is:

ConditionConstructor returnedKey source
CFM missing, or CFM is NoneNullCipherNone
V 5, or CFM is AESV3AES256Cipher bound to the file keyFile key, 32 bytes
CFM is V2ARCFourCipher bound to the object key#buildObjectKey
CFM is AESV2AES128Cipher bound to the object key#buildObjectKey
Anything elseThrows FormatError: Unknown crypto methodNone

Points that are easy to miss:

  1. NullCipher comes before the key check. Even when the document has no password (encryptionKey is null), any filter written as None can still be decrypted, because no key is needed. This is exactly what makes deferred prompting work: the body uses Identity, attachments use EFOpen, and reading the body touches no key.
  2. V 5 wins over CFM. The specification requires V 5 to use AES-256 unconditionally, but some producers write AESV2 in CFM. The source checks algorithm === 5 first and returns AES256Cipher directly, avoiding the erroneous CFM leading into the 128-bit branch (bug 2046659 in the comment).
  3. AESV3 also uses the file key. It does not call #buildObjectKey, because a 32-byte file key is already strong enough and the specification no longer requires object-level derivation.
  4. V 1 and V 2 take the trailing branch. There is no /CF there, so resolveCipher ignores the filter name and always returns RC4 bound to the object key. Both default filters on CipherTransform are null, so #getCipher(null) hits the same resolver under the __default__ key.

bind(null, key) returns a constructor with the key already bound. When new Cipher() runs, the key is already in the argument list, so instantiation needs no argument. This also explains why the cache in CipherTransform stores "constructors" rather than "keys": the key is closed over inside the constructor, and the cache only ensures that the same filter name is not resolved twice.

Design note: The file key, object key and cipher instance are produced at different layers. The factory computes only the file key; #buildObjectKey computes an object key from num and gen only when an object is fetched; CipherTransform runs new only when data is actually read. No mutable state is shared across the three layers, so one document can be read concurrently by many objects and many streams without cross-contamination.

Conclusion

crypto.js is PDF.js's implementation of the standard security handler: upwards it offers XRef, Parser and Writer a per-object encryption and decryption interface; downwards it performs byte transformations with its own RC4 and AES; in between sit two password algorithms and a layer of object key derivation.

Where It Sits in the Chain

StageModuleInputOutput
Read the encryption dictionaryXRef.parse/Encrypt and /ID[0] from the trailerCipherTransformFactory
Password validationThe CipherTransformFactory constructorPassword, /O, /U, /P, /OE, /UEThe file key encryptionKey
Object key#buildObjectKeyObject number, generation, file keyA 5 to 16 byte object key
Read a stringCipherTransform.decryptStringCiphertext stringPlaintext string
Read a streamCipherTransform.createStreamCiphertext BaseStreamDecryptStream
Write backCipherTransform.encryptStringPlaintext string or stream bytesCiphertext with IV and padding

In one sentence: the factory turns the encryption dictionary into a file key, the object number turns the file key into an object key, and the transform facade turns the object key into a streaming cipher instance.

Five Design Principles

  1. Primitives, algorithms and dispatch live on three separate layers. RC4 and AES know only byte arrays; PDF17 and PDF20 know only passwords and salts; the factory knows only the dictionary; CipherTransform knows only filter names. Each layer can be tested on its own, and PDF's version differences never leak into the cipher primitives.
  2. Keys are layered by lifetime. The file key lives as long as the document, an object key lives for one fetch, and a cipher instance lives for one stream or one string. Create any layer early or reuse it across objects, and streaming decryption loses its ordering.
  3. Cache constructors, not instances. CipherTransform.#cipherCache caches the mapping from filter name to constructor. Constructors are stateless and hold a key fixed by bind; instances are what carry the RC4 permutation table, the AES IV and the residual block. The cache therefore never mixes the state of two streams.
  4. Validate before decrypting, and report failures by layer. /U and /O are irreversible validation values, so a password can only be checked by recomputing them; "missing password", "wrong password" and "defer until an attachment is opened" are three different exceptions, letting the caller decide between showing an input box and reporting a format error.
  5. Compatibility first, specification as the fallback. Recover /Length from /CF when it is absent, shift left by three when bytes were written as bits, fall back to the raw password when SASLprep fails for R 6, treat invalid AES padding as unpadded, and still use AES-256 when a V 5 producer wrote the wrong CFM. Every one of these is "follow the specification first, then open the file if at all possible".

StreamPDF design principle: anything that can be handled ahead of time is absolutely not deferred to parse time; the server solves all performance problems once. The browser only fetches and renders the data it must.

Related articles