Change MessageGrammarValidator into a state machine

Instead of checking the list of packets for every new packet, check the
new packet given the state we've recorded from the previous packets.
This commit is contained in:
Daniel Huigens 2025-05-23 18:41:03 +02:00
parent f1c6f9c4e4
commit d94d9d462a
No known key found for this signature in database
GPG Key ID: CB064A128FA90686
9 changed files with 89 additions and 94 deletions

View File

@ -36,7 +36,7 @@ import {
OnePassSignaturePacket, OnePassSignaturePacket,
SignaturePacket SignaturePacket
} from './packet'; } from './packet';
import { getMessageGrammarValidator } from './packet/grammar'; import { MessageGrammarValidator } from './packet/grammar';
// A Message can contain the following packets // A Message can contain the following packets
const allowedMessagePackets = /*#__PURE__*/ util.constructAllowedPackets([ const allowedMessagePackets = /*#__PURE__*/ util.constructAllowedPackets([
@ -879,7 +879,7 @@ export async function readMessage({ armoredMessage, binaryMessage, config, ...re
} }
input = data; input = data;
} }
const packetlist = await PacketList.fromBinary(input, allowedMessagePackets, config, getMessageGrammarValidator()); const packetlist = await PacketList.fromBinary(input, allowedMessagePackets, config, new MessageGrammarValidator());
const message = new Message(packetlist); const message = new Message(packetlist);
message.fromStream = streamType; message.fromStream = streamType;
return message; return message;

View File

@ -28,7 +28,7 @@ import CompressedDataPacket from './compressed_data';
import OnePassSignaturePacket from './one_pass_signature'; import OnePassSignaturePacket from './one_pass_signature';
import SignaturePacket from './signature'; import SignaturePacket from './signature';
import PacketList from './packetlist'; import PacketList from './packetlist';
import { getMessageGrammarValidator } from './grammar'; import { MessageGrammarValidator } from './grammar';
// An AEAD-encrypted Data packet can contain the following packet types // An AEAD-encrypted Data packet can contain the following packet types
const allowedPackets = /*#__PURE__*/ util.constructAllowedPackets([ const allowedPackets = /*#__PURE__*/ util.constructAllowedPackets([
@ -106,7 +106,7 @@ class AEADEncryptedDataPacket {
await runAEAD(this, 'decrypt', key, streamClone(this.encrypted)), await runAEAD(this, 'decrypt', key, streamClone(this.encrypted)),
allowedPackets, allowedPackets,
config, config,
getMessageGrammarValidator() new MessageGrammarValidator()
); );
} }

View File

@ -25,7 +25,7 @@ import LiteralDataPacket from './literal_data';
import OnePassSignaturePacket from './one_pass_signature'; import OnePassSignaturePacket from './one_pass_signature';
import SignaturePacket from './signature'; import SignaturePacket from './signature';
import PacketList from './packetlist'; import PacketList from './packetlist';
import { getMessageGrammarValidator } from './grammar'; import { MessageGrammarValidator } from './grammar';
// A Compressed Data packet can contain the following packet types // A Compressed Data packet can contain the following packet types
const allowedPackets = /*#__PURE__*/ util.constructAllowedPackets([ const allowedPackets = /*#__PURE__*/ util.constructAllowedPackets([
@ -114,7 +114,7 @@ class CompressedDataPacket {
} }
// Decompressing a Compressed Data packet MUST also yield a valid OpenPGP Message // Decompressing a Compressed Data packet MUST also yield a valid OpenPGP Message
this.packets = await PacketList.fromBinary(await decompressionFn(this.compressed), allowedPackets, config, getMessageGrammarValidator()); this.packets = await PacketList.fromBinary(await decompressionFn(this.compressed), allowedPackets, config, new MessageGrammarValidator());
} }
/** /**

View File

@ -1,6 +1,4 @@
import { type Config } from '../config';
import enums from '../enums'; import enums from '../enums';
import util from '../util';
export class GrammarError extends Error { export class GrammarError extends Error {
constructor(...params: any[]) { constructor(...params: any[]) {
@ -14,74 +12,60 @@ export class GrammarError extends Error {
} }
} }
const encryptedDataPackets = new Set([
enums.packet.aeadEncryptedData,
enums.packet.symmetricallyEncryptedData,
enums.packet.symEncryptedIntegrityProtectedData
]);
const dataPackets = new Set([
enums.packet.literalData,
enums.packet.compressedData,
...encryptedDataPackets
]);
const isValidLiteralMessage = (tagList: enums.packet[]) => tagList.length === 1 && tagList[0] === enums.packet.literalData; export class MessageGrammarValidator {
const isValidCompressedMessage = (tagList: enums.packet[]) => tagList.length === 1 && tagList[0] === enums.packet.compressedData; sawDataPacket: boolean = false;
const isValidEncryptedMessage = (tagList: enums.packet[]) => { sawESKs: number = 0;
// Encrypted Message: Encrypted Data | ESK Sequence, Encrypted Data. sawOPSs: number = 0;
const isValidESKSequence = (tagList: enums.packet[]) => ( sawTrailingSigs: number = 0;
tagList.every(packetTag => new Set([enums.packet.publicKeyEncryptedSessionKey, enums.packet.symEncryptedSessionKey]).has(packetTag))
);
const encryptedDataPacketIndex = tagList.findIndex(tag => new Set([enums.packet.aeadEncryptedData, enums.packet.symmetricallyEncryptedData, enums.packet.symEncryptedIntegrityProtectedData]).has(tag));
if (encryptedDataPacketIndex < 0) {
return isValidESKSequence(tagList);
}
return (encryptedDataPacketIndex === tagList.length - 1) && recordPacket(packet: enums.packet) {
isValidESKSequence(tagList.slice(0, encryptedDataPacketIndex)); if (packet === enums.packet.publicKeyEncryptedSessionKey || packet === enums.packet.symEncryptedSessionKey) {
}; if (this.sawDataPacket) {
throw new GrammarError('Encrypted session key packet following data packet');
const isValidSignedMessage = (tagList: enums.packet[], acceptPartial: boolean) => {
// Signature Packet, OpenPGP Message | One-Pass Signed Message.
if (tagList.findIndex(tag => tag === enums.packet.signature) === 0) {
return isValidOpenPGPMessage(tagList.slice(1), acceptPartial);
}
// One-Pass Signed Message:
// One-Pass Signature Packet, OpenPGP Message, Corresponding Signature Packet.
if (tagList.findIndex(tag => tag === enums.packet.onePassSignature) === 0) {
const correspondingSigPacketIndex = util.findLastIndex(tagList, tag => tag === enums.packet.signature);
if (correspondingSigPacketIndex !== tagList.length - 1 && !acceptPartial) {
return false;
}
return isValidOpenPGPMessage(tagList.slice(1, correspondingSigPacketIndex < 0 ? undefined : correspondingSigPacketIndex), acceptPartial);
}
return false;
};
/**
* Implements grammar checks based on https://www.rfc-editor.org/rfc/rfc9580.html#section-10.3 .
* @param packetList - list of packet tags to validate; marker/padding/unknown packet tags are expected to have been already filtered out.
* @param acceptPartial - whether the list of tags corresponds to a partially-parsed message
* @returns whether the list of tags is valid
*/
const isValidOpenPGPMessage = (
packetList: enums.packet[],
acceptPartial: boolean
): boolean => {
return isValidLiteralMessage(packetList) ||
isValidCompressedMessage(packetList) ||
isValidEncryptedMessage(packetList) ||
isValidSignedMessage(packetList, acceptPartial);
};
export const getMessageGrammarValidator = () => {
let logged = false;
/**
* @throws on grammar error, provided `config.enforceGrammar` is enabled.
*/
return (list: number[], isPartial: boolean, config: Config): undefined => {
if (!isValidOpenPGPMessage(list, isPartial)) {
const error = new GrammarError(`Data does not respect OpenPGP grammar [${list}]`);
if (!logged) {
util.printDebugError(error);
logged = true;
} }
if (config.enforceGrammar) { this.sawESKs++;
throw error; } else if (packet === enums.packet.onePassSignature) {
if (this.sawDataPacket) {
throw new GrammarError('One-pass signature packet following data packet');
} }
if (this.sawESKs) {
throw new GrammarError('One-pass signature packet following encrypted session key packet');
}
this.sawOPSs++;
} else if (packet === enums.packet.signature) {
if (this.sawESKs) {
throw new GrammarError('Signature packet following encrypted session key packet');
}
if (this.sawDataPacket) {
this.sawTrailingSigs++;
}
} else if (dataPackets.has(packet)) {
if (this.sawDataPacket) {
throw new GrammarError('Multiple data packets in message');
}
if (this.sawESKs && !encryptedDataPackets.has(packet)) {
throw new GrammarError('Non-encrypted data packet following ESK packet');
}
this.sawDataPacket = true;
} else {
throw new GrammarError(`Unexpected packet: ${packet}`);
} }
}; }
};
recordEnd() {
if (this.sawOPSs !== this.sawTrailingSigs) {
throw new GrammarError('Mismatched one-pass signature and signature packets');
}
}
}

View File

@ -73,7 +73,6 @@ class PacketList extends Array {
if (config.additionalAllowedPackets.length) { if (config.additionalAllowedPackets.length) {
allowedPackets = { ...allowedPackets, ...util.constructAllowedPackets(config.additionalAllowedPackets) }; allowedPackets = { ...allowedPackets, ...util.constructAllowedPackets(config.additionalAllowedPackets) };
} }
const tagsRead = [];
this.stream = streamTransformPair(bytes, async (readable, writable) => { this.stream = streamTransformPair(bytes, async (readable, writable) => {
const reader = streamGetReader(readable); const reader = streamGetReader(readable);
const writer = streamGetWriter(writable); const writer = streamGetWriter(writable);
@ -95,7 +94,15 @@ class PacketList extends Array {
const packet = newPacketFromTag(parsed.tag, allowedPackets); const packet = newPacketFromTag(parsed.tag, allowedPackets);
// Unknown packets throw in the call above, we ignore them // Unknown packets throw in the call above, we ignore them
// in the grammar checker. // in the grammar checker.
tagsRead.push(parsed.tag); try {
grammarValidator?.recordPacket(parsed.tag);
} catch (e) {
if (config.enforceGrammar) {
throw e;
} else {
util.printDebugError(e);
}
}
packet.packets = new PacketList(); packet.packets = new PacketList();
packet.fromStream = util.isStream(parsed.packet); packet.fromStream = util.isStream(parsed.packet);
wasStream = packet.fromStream; wasStream = packet.fromStream;
@ -179,7 +186,15 @@ class PacketList extends Array {
if (done) { if (done) {
// Here we are past the MDC check for SEIPDv1 data, hence // Here we are past the MDC check for SEIPDv1 data, hence
// the data is always authenticated at this point. // the data is always authenticated at this point.
grammarValidator?.(tagsRead, false, config); try {
grammarValidator?.recordEnd();
} catch (e) {
if (config.enforceGrammar) {
throw e;
} else {
util.printDebugError(e);
}
}
await writer.ready; await writer.ready;
await writer.close(); await writer.close();
return; return;
@ -198,12 +213,8 @@ class PacketList extends Array {
this.push(value); this.push(value);
} else { } else {
this.stream = null; this.stream = null;
break;
} }
if (supportsStreaming(value.constructor.tag)) { if (done || supportsStreaming(value.constructor.tag)) {
if (!delayErrors) {
grammarValidator?.(tagsRead, true, config);
}
break; break;
} }
} }

View File

@ -28,7 +28,7 @@ import OnePassSignaturePacket from './one_pass_signature';
import SignaturePacket from './signature'; import SignaturePacket from './signature';
import PacketList from './packetlist'; import PacketList from './packetlist';
import { UnsupportedError } from './packet'; import { UnsupportedError } from './packet';
import { getMessageGrammarValidator } from './grammar'; import { MessageGrammarValidator } from './grammar';
// A SEIP packet can contain the following packet types // A SEIP packet can contain the following packet types
const allowedPackets = /*#__PURE__*/ util.constructAllowedPackets([ const allowedPackets = /*#__PURE__*/ util.constructAllowedPackets([
@ -224,7 +224,7 @@ class SymEncryptedIntegrityProtectedDataPacket {
// MUST yield a valid OpenPGP Message. // MUST yield a valid OpenPGP Message.
// - Decrypting a version 2 Symmetrically Encrypted and Integrity Protected Data packet // - Decrypting a version 2 Symmetrically Encrypted and Integrity Protected Data packet
// MUST yield a valid Optionally Padded Message. // MUST yield a valid Optionally Padded Message.
this.packets = await PacketList.fromBinary(packetbytes, allowedPackets, config, getMessageGrammarValidator(), delayErrors); this.packets = await PacketList.fromBinary(packetbytes, allowedPackets, config, new MessageGrammarValidator(), delayErrors);
return true; return true;
} }
} }

View File

@ -35,7 +35,7 @@ habAyxd1AGKaNp1wbGFpbnRleHQgbWVzc2FnZQ==
await expect( await expect(
openpgp.readMessage({ armoredMessage: skeskPlusLiteralData, config: { enforceGrammar: true } }) openpgp.readMessage({ armoredMessage: skeskPlusLiteralData, config: { enforceGrammar: true } })
).to.be.rejectedWith(/Data does not respect OpenPGP grammar/); ).to.be.rejectedWith(/Non-encrypted data packet following ESK packet/);
}); });
it('openpgp.readSignature', async function() { it('openpgp.readSignature', async function() {

View File

@ -13,7 +13,7 @@ import * as random from '../../src/crypto/random';
import * as input from './testInputs.js'; import * as input from './testInputs.js';
import { mockCryptoRandomGenerator, restoreCryptoRandomGenerator } from '../mockRandom.ts'; import { mockCryptoRandomGenerator, restoreCryptoRandomGenerator } from '../mockRandom.ts';
import { getMessageGrammarValidator } from '../../src/packet/grammar.js'; import { MessageGrammarValidator } from '../../src/packet/grammar.js';
function stringify(array) { function stringify(array) {
if (stream.isStream(array)) { if (stream.isStream(array)) {
@ -1379,7 +1379,7 @@ kePFjAnu9cpynKXu3usf8+FuBw2zLsg1Id1n7ttxoAte416KjBN9lFBt8mcu
const packets = new openpgp.PacketList(); const packets = new openpgp.PacketList();
packets.push(new openpgp.LiteralDataPacket()); packets.push(new openpgp.LiteralDataPacket());
packets.push(new openpgp.LiteralDataPacket()); packets.push(new openpgp.LiteralDataPacket());
await expect(openpgp.PacketList.fromBinary(packets.write(), allAllowedPackets, openpgp.config, getMessageGrammarValidator())).to.be.rejectedWith(/Data does not respect OpenPGP grammar/); await expect(openpgp.PacketList.fromBinary(packets.write(), allAllowedPackets, openpgp.config, new MessageGrammarValidator())).to.be.rejectedWith(/Multiple data packets in message/);
}); });
it('reject duplicate literal packet inside encrypted data', async () => { it('reject duplicate literal packet inside encrypted data', async () => {
@ -1397,7 +1397,7 @@ kePFjAnu9cpynKXu3usf8+FuBw2zLsg1Id1n7ttxoAte416KjBN9lFBt8mcu
binaryMessage: packets.write() binaryMessage: packets.write()
}), }),
sessionKeys: [{ algorithm: 'aes128', data: new Uint8Array(16) }] sessionKeys: [{ algorithm: 'aes128', data: new Uint8Array(16) }]
})).to.be.rejectedWith(/Data does not respect OpenPGP grammar/); })).to.be.rejectedWith(/Multiple data packets in message/);
}); });
it('reject duplicate literal packet inside encrypted data (streaming)', async () => { it('reject duplicate literal packet inside encrypted data (streaming)', async () => {
@ -1424,7 +1424,7 @@ kePFjAnu9cpynKXu3usf8+FuBw2zLsg1Id1n7ttxoAte416KjBN9lFBt8mcu
allowUnauthenticatedStream: true allowUnauthenticatedStream: true
} }
}); });
await expect(stream.readToEnd(decrypted.data)).to.be.rejectedWith(/Data does not respect OpenPGP grammar/); await expect(stream.readToEnd(decrypted.data)).to.be.rejectedWith(/Multiple data packets in message/);
}); });
it('reject duplicate literal packet inside encrypted data (MDC error gets precedence)', async () => { it('reject duplicate literal packet inside encrypted data (MDC error gets precedence)', async () => {
@ -1529,13 +1529,13 @@ kePFjAnu9cpynKXu3usf8+FuBw2zLsg1Id1n7ttxoAte416KjBN9lFBt8mcu
packets.push(padding); packets.push(padding);
packets.push(new openpgp.MarkerPacket()); packets.push(new openpgp.MarkerPacket());
packets.push(new openpgp.LiteralDataPacket()); packets.push(new openpgp.LiteralDataPacket());
const parsed = await openpgp.PacketList.fromBinary(packets.write(), allAllowedPackets, openpgp.config, getMessageGrammarValidator()); const parsed = await openpgp.PacketList.fromBinary(packets.write(), allAllowedPackets, openpgp.config, new MessageGrammarValidator());
expect(parsed.length).to.equal(1); // marker and padding packets are always dropped on parsing expect(parsed.length).to.equal(1); // marker and padding packets are always dropped on parsing
}); });
it('accepts unknown packets', async () => { it('accepts unknown packets', async () => {
const unknownPacketTag63 = util.hexToUint8Array('ff0a750064bf943d6e756c6c'); // non-critical tag const unknownPacketTag63 = util.hexToUint8Array('ff0a750064bf943d6e756c6c'); // non-critical tag
const parsed = await openpgp.PacketList.fromBinary(unknownPacketTag63, allAllowedPackets, openpgp.config, getMessageGrammarValidator()); const parsed = await openpgp.PacketList.fromBinary(unknownPacketTag63, allAllowedPackets, openpgp.config, new MessageGrammarValidator());
expect(parsed.length).to.equal(1); expect(parsed.length).to.equal(1);
}); });
}); });

View File

@ -1881,10 +1881,10 @@ hkJiXopCSWKSlQInL1devkJJUWJmTmZeugJYlpdLAagQJM0JpsCqIQZwKgAA
expect(pubKey.getKeys(keyIDs[0])).to.not.be.empty; expect(pubKey.getKeys(keyIDs[0])).to.not.be.empty;
await openpgp.verify({ verificationKeys: [pubKey], message, config: { minRSABits: 1024 } }).then(async ({ data, signatures }) => { await openpgp.verify({ verificationKeys: [pubKey], message, config: { minRSABits: 1024 } }).then(async ({ data, signatures }) => {
await expect(stream.readToEnd(data)).to.be.rejectedWith('Data does not respect OpenPGP grammar'); await expect(stream.readToEnd(data)).to.be.rejectedWith('Mismatched one-pass signature and signature packets');
expect(signatures).to.have.length(1); expect(signatures).to.have.length(1);
await expect(signatures[0].verified).to.be.rejectedWith('Data does not respect OpenPGP grammar'); await expect(signatures[0].verified).to.be.rejectedWith('Mismatched one-pass signature and signature packets');
await expect(signatures[0].signature).to.be.rejectedWith('Data does not respect OpenPGP grammar'); await expect(signatures[0].signature).to.be.rejectedWith('Mismatched one-pass signature and signature packets');
}); });
await openpgp.verify({ verificationKeys: [pubKey], message: messageWithoutGrammar, config: { minRSABits: 1024 } }).then(async ({ data, signatures }) => { await openpgp.verify({ verificationKeys: [pubKey], message: messageWithoutGrammar, config: { minRSABits: 1024 } }).then(async ({ data, signatures }) => {
expect(await stream.readToEnd(data)).to.equal(plaintext); expect(await stream.readToEnd(data)).to.equal(plaintext);