Files
relay/filtermail/src/openpgp.rs
T
Jagoda Estera Ślązak 5c603db830 feat: Support legacy, pre-OpenPGP packet format (#44)
Support legacy packet format for compatibility with GnuPG.

Closes: #43

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-08-18 12:21:27 +02:00

351 lines
15 KiB
Rust

//! OpenPGP payload checker.
use crate::error;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
/// Tries to get the byte `$idx` of the array slice `$payload`.
///
/// Returns [`error::Error::TruncatedHeader`] in the outer function, if `$idx` is out of range.
macro_rules! get_byte {
($payload:expr, $idx:expr) => {
*$payload.get($idx).ok_or(error::Error::TruncatedHeader)?
};
}
/// Checks the OpenPGP payload.
///
/// OpenPGP payload must consist only of `PKESK` and `SKESK` packets terminated by a single `SEIPD` packet.
///
/// Returns `Ok(true)` if OpenPGP payload is correct, `Ok(false)` otherwise.
///
/// # Errors
///
/// Returns an [`error::Error::TruncatedHeader`] if the OpenPGP packet header is truncated.
fn check_openpgp_payload(payload: &[u8]) -> Result<bool, error::Error> {
// From RFC9580; we have to support legacy format too, for compatibility with GnuPG.
//
// +---------------+
// Encoded Packet Type ID: |7 6 5 4 3 2 1 0|
// +---------------+
// OpenPGP format:
// Bit 7 -- always one
// Bit 6 -- always one
// Bits 5 to 0 -- Packet Type ID
//
// Legacy format:
// Bit 7 -- always one
// Bit 6 -- always zero
// Bits 5 to 2 -- Packet Type ID
// Bits 1 to 0 -- length-type
let mut i: usize = 0;
while i < payload.len() {
let bits_7_6 = get_byte!(payload, i) & 0xC0;
// bit 6 is 0 for legacy (GnuPG)
let legacy = if bits_7_6 == 0xC0 {
false
} else if bits_7_6 == 0x80 {
true
} else {
log::debug!(
"check_openpgp_payload: i={i} bits 7 and 6 doesn't indicate OpenPGP or legacy format"
);
return Ok(false);
};
let packet_type_id = if legacy {
// bits 5 to 2
(get_byte!(payload, i) & 0x3C) >> 2
} else {
// bits 5 to 0
get_byte!(payload, i) & 0x3F
};
if legacy {
// Body length calculation - Legacy, RFC9580 4.2.2
let length_type = get_byte!(payload, i) & 0x03;
i += 1;
let body_len: usize;
match length_type {
0 => {
// One-octet length.
body_len = get_byte!(payload, i) as usize;
i += 1;
}
1 => {
// Two-octet length.
body_len = ((get_byte!(payload, i) as usize) << 8)
+ (get_byte!(payload, i + 1) as usize);
i += 2;
}
2 => {
// Four-octet length.
body_len = ((get_byte!(payload, i) as usize) << 24)
+ ((get_byte!(payload, i + 1) as usize) << 16)
+ ((get_byte!(payload, i + 2) as usize) << 8)
+ (get_byte!(payload, i + 3) as usize);
i += 4;
}
// 3 - indeterminate length, not supported
// RFC9580 4.2.2:
// "An implementation MUST NOT generate a Legacy format packet with indeterminate length."
// We hope they don't.
_ => {
log::debug!(
"check_openpgp_payload: i={i} Indeterminate length (3) length-type in legacy packet format"
);
return Ok(false);
}
};
i += body_len;
} else {
// Body length calculation - OpenPGP, RFC9580 4.2.1
i += 1;
while get_byte!(payload, i) >= 224 && get_byte!(payload, i) < 255 {
// Partial body length.
let partial_length = 1usize << (get_byte!(payload, i) & 0x1F);
i += 1 + partial_length;
}
let body_len: usize;
if get_byte!(payload, i) < 192 {
// One-octet length.
body_len = get_byte!(payload, i) as usize;
i += 1;
} else if get_byte!(payload, i) < 224 {
// Two-octet length.
body_len = (((get_byte!(payload, i) as usize) - 192) << 8)
+ (get_byte!(payload, i + 1) as usize)
+ 192;
i += 2;
} else if get_byte!(payload, i) == 255 {
// Five-octet length.
body_len = ((get_byte!(payload, i + 1) as usize) << 24)
| ((get_byte!(payload, i + 2) as usize) << 16)
| ((get_byte!(payload, i + 3) as usize) << 8)
| (get_byte!(payload, i + 4) as usize);
i += 5;
} else {
// Impossible, partial body length was processed above.
log::debug!("check_openpgp_payload: i={i} Invalid body length");
return Ok(false);
}
i += body_len;
}
if i == payload.len() {
// Last packet should be
// Symmetrically Encrypted and Integrity Protected Data Packet (SEIPD)
//
// This is the only place where this function may return `True`.
log::debug!("check_openpgp_payload: i={i} packat_type_id={packet_type_id}");
return Ok(packet_type_id == 18);
} else if ![1, 3].contains(&packet_type_id) {
// All packets except the last one must be either
// Public-Key Encrypted Session Key Packet (PKESK)
// or
// Symmetric-Key Encrypted Session Key Packet (SKESK)
log::debug!("check_openpgp_payload: i={i} packet_type_id={packet_type_id}");
return Ok(false);
}
}
Ok(false)
}
/// Check the armored PGP message for invalid content.
///
/// Returns `true` if the `payload` is a valid PGP message,
/// `outgoing` informs whether the message is outgoing or incoming
pub fn check_armored_payload(payload: &str, outgoing: bool) -> bool {
const PREFIX: &str = "-----BEGIN PGP MESSAGE-----\r\n";
let Some(payload) = payload.strip_prefix(PREFIX) else {
log::debug!("check_armored_payload: Did not find PGP MESSAGE prefix");
return false;
};
let payload = payload.trim_end_matches("\r\n");
const SUFFIX: &str = "-----END PGP MESSAGE-----";
let Some(mut payload) = payload.strip_suffix(SUFFIX) else {
log::debug!("check_armored_payload: Did not find PGP MESSAGE suffix");
return false;
};
const VERSION_COMMENT: &str = "Version: ";
if payload.starts_with(VERSION_COMMENT) {
// Disallow comments in outgoing messages
if outgoing {
log::debug!("check_armored_payload: Comment found in outgoing message");
return false;
}
// Remove comments from incoming messages
if let Some((_, right)) = payload.split_once("\r\n") {
payload = right;
}
}
let mut payload = payload.trim_start_matches("\r\n");
// Remove CRC24.
if let Some((left, _)) = payload.rsplit_once('=') {
payload = left;
}
let payload = payload.replace(['\r', '\n'], "");
let payload = match BASE64_STANDARD.decode(payload.as_bytes()) {
Ok(v) => v,
Err(_) => {
log::debug!("check_armored_payload: Base64 decoding failed");
return false;
}
};
check_openpgp_payload(&payload).unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::*;
#[rstest]
#[case::valid(r#"-----BEGIN PGP MESSAGE-----
wU4DhW3gBZ/VvCYSAQdA8bMs2spwbKdGjVsL1ByPkNrqD7frpB73maeL6I6SzDYg
O5G53tv339RdKq3WRcCtEEvxjHlUx2XNwXzC04BpmfvBTgNfPUyLDzjXnxIBB0Ae
8ymwGvXMCCimHXN0Dg8Ui62KOi03h0UgheoHWovJSCDF4CKre/xtFr3nL7lq/PKI
JsjVNz7/RK9FSXF6WwfONtLCyQGEuVAsB/KXfCBEyfKhaMwGHvhujRidGW5uV1no
lMGl3ODmo29Lgeu2uSE7EpJRZoe6hU6ddmBkqxax61ZtkaFlGFFpdo2K8balNNdz
ZsJ/9mmI9x3oOJ4/l1nhQbUO9ADbs7gJhFdV5Qkp30b5fCI7bU+aoe1ccBbLe/WM
YUty1PqcuQT7XjA+XmYuL261tvW8pBetT+i33/E2d8PzzYt2IuK9qeevyS+yxdwA
kfwejFWzzsUlJaDxs1x4XOxkMgSj+jo+g12dFOb7fyClsAnq23iDb8AuaT/BScAI
+lO+gher69+6LmM7VGHLG5k762J1jTaQCaKt1s8TAWV99Eo4491vL6fyvk3l/Cfg
RXSwiWFgj19Pn0Rq7CD9v22UE2vdUMBTcV4aw79mClk1YQ23jbF0y5DCjPdJ62Zo
tskBgFt3NoWV80jZ76zIBLrrjLwCCll8JjJtFwSkt2GX5RFBsVa4A8IDht9RtEk7
rrHgbSZQfkauEi/mH3/6CDZoLqSHudUZ7d4MaJwun1TkFYGe2ORwGJd4OBj3oGJp
H8YBwCpk///L/fKjX0Gg3M8nrpM4wrRFhPKidAgO/kcm25X4+ZHlVkWBTCt5RWKI
fHh6oLDZCqCfcgMkE1KKmwfIHaUkhq5BPRigwy6i5dh1DM4+1UCLh3dxzVbqE9b9
61NB19nXdRtDA2sOUnj9ve6m/wEPyCb6/zBQZqvCBYb1/AjdXpUrFT+DbpfyxaXN
XfhDVb5mNqNM/IVj0V5fvTc6vOfYbzQtPm10H+FdWWfb+rJRfyC3MA2w2IqstFe3
w3bu2iE6CQvSqRvge+ZqLKt/NqYwOURiUmpuklbl3kPJ97+mfKWoiqk8Iz1VY+bb
NMUC7aoGv+jcoj+WS6PYO8N6BeRVUUB3ZJSf8nzjgxm1/BcM+UD3BPrlhT11ODRs
baifGbprMWwt3dhb8cQgRT8GPdpO1OsDkzL6iikMjLHWWiA99GV6ruiHsIPw6boW
A6/uSOskbDHOROotKmddGTBd0iiHXAoQsJFt1ZjUkt6EHrgWs+GAvrvKpXs1mrz8
uj3GwEFrHS+Xuf2UDgpszYT3hI2cL/kUtGakVR7m7vVMZqXBUbZdGAEb1PZNPwsI
E4aMK02+EVB+tSN4Fzj99N2YD0inVYt+oPjr2tHhUS6aSGBNS/48Ki47DOg4Sxkn
lkOWnEbCD+XTnbDd
=agR5
-----END PGP MESSAGE-----"#, (true, true))]
// GnuPG uses a legacy packet format that was obsoleted back in 1998 (RFC2440);
// despite RFC9580 stating "The Legacy packet format SHOULD NOT be used to generate new data".
#[case::gnupg(r#"-----BEGIN PGP MESSAGE-----
hF4DFYw3E47UG/ASAQdA3JnkJLJJALitYG/jzzEuF0p+hEBsgLTicaEJkJerInkw
SftuCMBjxv4D6rTqNkBn7IQh6gmlUKrUNBVEnq0V1InVF9o0kDpZfUUaB5ajNDMb
hF4DMiMKov7/CVgSAQdA7ib6HK7UVW2IdAM5PILbtqzuynukooXdg9YWWamjal8w
h/L0BxbtUFzI3U847dvzwtsW7A9CZXpi45OMXRC9mO3G/FAY1fW+C6PwObT7X1qK
0sDSAbyYddCihL1UEl6EigtD/YQezCTCydzstD2cRdV75lmUqzMCR8NbfvdttBbl
g04ZZtoHm1DSnXIAN4iFzW4EBgNVUDCnqO8wyYmWw4OCOt45IkTXGQUICGxeVh6g
4Pdj4OzSrg/mTZGIDjE2h/osVpPEQjwcAus73EZC4sz4n2U+OMHsq0M54Ds7wyGv
1KBzcQQ5Rc/DPPT0INnl58datm2HGKtWFWYab9/cw/fnQ+oYhmpkZbdITTmnTAyn
CHih86i0w6gXsX99iRLe6yP8BcU3rCCflsCp5zzlcFYViTPgEn1W8H32VOXulfcV
rpRFtc5yzjFj8Dvq9gI9yGIgqiS7oEAXdRBYhmBRTydMcbS1bCPDWy5w95Cp2VzR
x8cBq5e+27QTl9YNHr4xeric7del79wktJFiINzUCQPwkwM9ud2EJ1zN8Owr2Dp8
JvS2ioSEK5J2Lc95vDLmbVnMuKmVW6qD+fEdtYSHnSIc/t+iYG3GlaFyvO8t0Jav
cbsijrbYhoSOq67KseQWvfL+gL5u
=WWqc
-----END PGP MESSAGE-----"#, (true, true))]
#[case::with_comment(r#"-----BEGIN PGP MESSAGE-----
Version: 1
wU4DhW3gBZ/VvCYSAQdA8bMs2spwbKdGjVsL1ByPkNrqD7frpB73maeL6I6SzDYg
O5G53tv339RdKq3WRcCtEEvxjHlUx2XNwXzC04BpmfvBTgNfPUyLDzjXnxIBB0Ae
8ymwGvXMCCimHXN0Dg8Ui62KOi03h0UgheoHWovJSCDF4CKre/xtFr3nL7lq/PKI
JsjVNz7/RK9FSXF6WwfONtLCyQGEuVAsB/KXfCBEyfKhaMwGHvhujRidGW5uV1no
lMGl3ODmo29Lgeu2uSE7EpJRZoe6hU6ddmBkqxax61ZtkaFlGFFpdo2K8balNNdz
ZsJ/9mmI9x3oOJ4/l1nhQbUO9ADbs7gJhFdV5Qkp30b5fCI7bU+aoe1ccBbLe/WM
YUty1PqcuQT7XjA+XmYuL261tvW8pBetT+i33/E2d8PzzYt2IuK9qeevyS+yxdwA
kfwejFWzzsUlJaDxs1x4XOxkMgSj+jo+g12dFOb7fyClsAnq23iDb8AuaT/BScAI
+lO+gher69+6LmM7VGHLG5k762J1jTaQCaKt1s8TAWV99Eo4491vL6fyvk3l/Cfg
RXSwiWFgj19Pn0Rq7CD9v22UE2vdUMBTcV4aw79mClk1YQ23jbF0y5DCjPdJ62Zo
tskBgFt3NoWV80jZ76zIBLrrjLwCCll8JjJtFwSkt2GX5RFBsVa4A8IDht9RtEk7
rrHgbSZQfkauEi/mH3/6CDZoLqSHudUZ7d4MaJwun1TkFYGe2ORwGJd4OBj3oGJp
H8YBwCpk///L/fKjX0Gg3M8nrpM4wrRFhPKidAgO/kcm25X4+ZHlVkWBTCt5RWKI
fHh6oLDZCqCfcgMkE1KKmwfIHaUkhq5BPRigwy6i5dh1DM4+1UCLh3dxzVbqE9b9
61NB19nXdRtDA2sOUnj9ve6m/wEPyCb6/zBQZqvCBYb1/AjdXpUrFT+DbpfyxaXN
XfhDVb5mNqNM/IVj0V5fvTc6vOfYbzQtPm10H+FdWWfb+rJRfyC3MA2w2IqstFe3
w3bu2iE6CQvSqRvge+ZqLKt/NqYwOURiUmpuklbl3kPJ97+mfKWoiqk8Iz1VY+bb
NMUC7aoGv+jcoj+WS6PYO8N6BeRVUUB3ZJSf8nzjgxm1/BcM+UD3BPrlhT11ODRs
baifGbprMWwt3dhb8cQgRT8GPdpO1OsDkzL6iikMjLHWWiA99GV6ruiHsIPw6boW
A6/uSOskbDHOROotKmddGTBd0iiHXAoQsJFt1ZjUkt6EHrgWs+GAvrvKpXs1mrz8
uj3GwEFrHS+Xuf2UDgpszYT3hI2cL/kUtGakVR7m7vVMZqXBUbZdGAEb1PZNPwsI
E4aMK02+EVB+tSN4Fzj99N2YD0inVYt+oPjr2tHhUS6aSGBNS/48Ki47DOg4Sxkn
lkOWnEbCD+XTnbDd
=agR5
-----END PGP MESSAGE-----"#, (false, true))]
#[case::invalid_base64(r#"-----BEGIN PGP MESSAGE-----
wU4DhW3gBZ/VvCYSAQdA8bMs2spwbKdGjVsL1ByPkNrqD7frpB73maeL6I6SzDYg
O5G53tv339RdKq3WRcCtEEvxjHlUx2XNwXzC04BpmfvBTgNfPUyLDzjXnxIBB0Ae
8ymwGvXMCCimHXN0Dg8Ui62KOi03h0UgheoHWovJSCDF4CKre/xtFr3nL7lq/PKI
JsjVNz7/RK9FSXF6WwfONtLCyQGEuVAsB/KXfCBEyfKhaMwGHvhujRidGW5uV1no
lMGl3ODmo29Lgeu2uSE7EpJRZoe6hU6ddmBkqxax61ZtkaFlGFFpdo2K8balNNdz
ZsJ/9mmI9x3oOJ4/l1nhQbUO9ADbs7gJhFdV5Qkp30b5fCI7bU+aoe1ccBbLe/WM
YUty1PqcuQT7XjA+XmYuL261tvW8pBetT+i33/E2d8PzzYt2IuK9qeevyS+yxdwA
kfwejFWzzsUlJaDxs1x4XOxkMgSj+jo+g12dFOb7fyClsAnq23iDb8AuaT/BScAI
+lO+gher69+6LmM7VGHLG5k762J1jTaQCaKt1s8TAWV99Eo4491vL6fyvk3l/Cfg
RXSwiWFgj19Pn0Rq7CD9v22UE2vdUMBTcV4aw79mClk1YQ23jbF0y5DCjPdJ62Zo
tskBgFt3NoWV80jZ76zIBLrrjLwCCll8JjJtFwSkt2GX5RFBsVa4A8IDht9RtEk7
rrHgbSZQfkauEi/mH3/6CDZoLqSHudUZ7d4MaJwun1TkFYGe2ORwGJd4OBj3oGJp
H8YBwCpk///L/fKjX0Gg3M8nrpM4wrRFhPKidAgO/kcm25X4+ZHlVkWBTCt5RWKI
fHh6oLDZCqCfcgMkE1KKmwfIHaUkhq5BPRigwy6i5dh1DM4+1UCLh3dxzVbqE9b9
61NB19nXdRtDA2sOUnj9ve6m/wEPyCb6/zBQZqvCBYb1/AjdXpUrFT+DbpfyxaXN
XfhDVb5mNqNM/IVj0V5fvTc6vOfYbzQtPm10H+FdWWfb+rJRfyC3MA2w2IqstFe3
w3bu2iE6CQvSqRvge+ZqLKt/NqYwOURiUmpuklbl3kPJ97+mfKWoiqk8Iz1VY+bb
NMUC7aoGv+jcoj+WS6PYO8N6BeRVUUB3ZJSf8nzjgxm1/BcM+UD3BPrlhT11ODRs
baifGbprMWwt3dhb8cQgRT8GPdpO1OsDkzL6iikMjLHWWiA99GV6ruiHsIPw6boW
A6/uSOskbDHOROotKmddGTBd0iiHXAoQsJFt1ZjUkt6EHrgWs+GAvrvKpXs1mrz8
uj3GwEFrHS+Xuf2UDgpszYT3hI2cL/kUtGakVR7m7vVMZqXBUbZdGAEb1PZNPwsI
E4aMK02+EVB+tSN4Fzj99N2YD0inVYt+oPjr2tHhUS6aSGBNS/48Ki47DOg4Sxkn
lkOWnEbCD+XTnbDd=
=agR5
-----END PGP MESSAGE-----"#, (false, false))]
#[case::invalid_non_pgp_base64(r#"-----BEGIN PGP MESSAGE-----
RGVsdGEgQ2hhdCBpcyBhIHJlbGlhYmxlLCBkZWNlbnRyYWxpemVkIGFuZCBzZWN1cmUgaW5zdGFu
dCBtZXNzYWdpbmcgYXBwLCBhdmFpbGFibGUgZm9yIG1vYmlsZSBhbmQgZGVza3RvcCBwbGF0Zm9y
bXMuCgogICAgSW5zdGFudCBjcmVhdGlvbiBvZiBwcml2YXRlIGNoYXQgcHJvZmlsZXMgd2l0aCBz
ZWN1cmUgYW5kIGludGVyb3BlcmFibGUgY2hhdG1haWwgcmVsYXlzIHRoYXQgb2ZmZXIgaW5zdGFu
dCBtZXNzYWdlIGRlbGl2ZXJ5LCBhbmQgUHVzaCBOb3RpZmljYXRpb25zIGZvciBpT1MgYW5kIEFu
ZHJvaWQgZGV2aWNlcy4KCiAgICBQZXJ2YXNpdmUgbXVsdGktcHJvZmlsZSBhbmQgbXVsdGktZGV2
aWNlIHN1cHBvcnQgb24gYWxsIHBsYXRmb3JtcyBhbmQgYmV0d2VlbiBkaWZmZXJlbnQgY2hhdG1h
aWwgYXBwcy4KCiAgICBJbnRlcmFjdGl2ZSBpbi1jaGF0IGFwcHMgZm9yIGdhbWluZyBhbmQgY29s
bGFib3JhdGlvbgoKICAgIEF1ZGl0ZWQgZW5kLXRvLWVuZCBlbmNyeXB0aW9uIHNhZmUgYWdhaW5z
dCBuZXR3b3JrIGFuZCBzZXJ2ZXIgYXR0YWNrcy4KCiAgICBGcmVlIGFuZCBPcGVuIFNvdXJjZSBz
b2Z0d2FyZSwgYm90aCBhcHAgYW5kIHNlcnZlciBzaWRlLCBidWlsdCBvbiBJbnRlcm5ldCBTdGFu
ZGFyZHMuCgo=
=4cf0a3
-----END PGP MESSAGE-----"#, (false, false))]
#[case::invalid_cleartext(r#"-----BEGIN PGP MESSAGE-----
Definitely not base64 encoded PGP message content.
-----END PGP MESSAGE-----"#, (false, false))]
#[case::invalid_no_begin(r#"-----END PGP MESSAGE-----"#, (false, false))]
#[case::invalid_no_end(r#"-----BEGIN PGP MESSAGE-----"#, (false, false))]
fn test_check_armored_payload(#[case] pgp_message: &str, #[case] expected: (bool, bool)) {
let (expected_outgoing, expected_incoming) = expected;
let result = check_armored_payload(&pgp_message.replace('\n', "\r\n"), true);
assert_eq!(result, expected_outgoing);
let result = check_armored_payload(&pgp_message.replace('\n', "\r\n"), false);
assert_eq!(result, expected_incoming);
}
}