sdjournal 0.1.15

Pure Rust systemd journal reader and query engine
Documentation
use super::fsprg::{FsprgParams, FsprgState};
use super::{HmacSha256, TAG_LENGTH, VERIFY_READ_CHUNK_SIZE};
use crate::error::{LimitKind, Result, SdJournalError};
use crate::file::JournalFile;
use crate::format::{
    OBJECT_DATA, OBJECT_DATA_HASH_TABLE, OBJECT_ENTRY, OBJECT_ENTRY_ARRAY, OBJECT_FIELD,
    OBJECT_FIELD_HASH_TABLE, OBJECT_TAG, ObjectHeader,
};
use crate::util::{checked_add_u64, read_u64_le};
use hmac::{KeyInit as _, Mac as _};

pub(crate) fn verify_file_seal(file: &JournalFile, params: &FsprgParams) -> Result<()> {
    let header = file.header();
    if !header.is_sealed() {
        return Ok(());
    }

    let header_size = header.header_size;
    let tail_object_offset = header.tail_object_offset;
    let used_size = file.used_size();

    if header_size < 184 || header_size > used_size || !header_size.is_multiple_of(8) {
        return Err(SdJournalError::Corrupt {
            path: Some(file.path().to_path_buf()),
            offset: Some(88),
            reason: format!("invalid header_size: {header_size}"),
        });
    }

    if tail_object_offset == 0 {
        return Err(SdJournalError::Corrupt {
            path: Some(file.path().to_path_buf()),
            offset: Some(136),
            reason: "sealed journal contains no objects".to_string(),
        });
    }
    if tail_object_offset < header_size || tail_object_offset >= used_size {
        return Err(SdJournalError::Corrupt {
            path: Some(file.path().to_path_buf()),
            offset: Some(136),
            reason: format!(
                "invalid tail_object_offset: {tail_object_offset} (header_size={header_size}, used_size={used_size})"
            ),
        });
    }

    let mut fsprg = FsprgState::new(params);
    let mut n_tags: u64 = 0;
    let mut last_epoch: u64 = 0;
    let mut last_tag_end: u64 = 0;

    let mut p = header_size;
    while p <= tail_object_offset {
        let oh = read_object_header(file, p)?;

        if oh.object_type == OBJECT_ENTRY && n_tags == 0 {
            return Err(SdJournalError::Corrupt {
                path: Some(file.path().to_path_buf()),
                offset: Some(p),
                reason: "sealed journal has ENTRY before first TAG".to_string(),
            });
        }

        if oh.object_type == OBJECT_TAG {
            let tag = read_tag_object(file, p, &oh)?;

            if tag.seqnum != n_tags.saturating_add(1) {
                return Err(SdJournalError::Corrupt {
                    path: Some(file.path().to_path_buf()),
                    offset: Some(p),
                    reason: format!(
                        "tag sequence number out of sync ({} != {})",
                        tag.seqnum,
                        n_tags.saturating_add(1)
                    ),
                });
            }

            if header.is_sealed_continuous() {
                if !(n_tags == 0
                    || (n_tags == 1 && tag.epoch == last_epoch)
                    || tag.epoch == last_epoch.saturating_add(1))
                {
                    return Err(SdJournalError::Corrupt {
                        path: Some(file.path().to_path_buf()),
                        offset: Some(p),
                        reason: format!(
                            "epoch sequence not continuous ({} vs {})",
                            tag.epoch, last_epoch
                        ),
                    });
                }
            } else if tag.epoch < last_epoch {
                return Err(SdJournalError::Corrupt {
                    path: Some(file.path().to_path_buf()),
                    offset: Some(p),
                    reason: format!(
                        "epoch sequence out of sync ({} < {})",
                        tag.epoch, last_epoch
                    ),
                });
            }

            fsprg.seek(tag.epoch)?;
            let hmac_key = fsprg.get_key(TAG_LENGTH, 0);
            let mut mac =
                HmacSha256::new_from_slice(&hmac_key).map_err(|_| SdJournalError::Corrupt {
                    path: Some(file.path().to_path_buf()),
                    offset: Some(p),
                    reason: "failed to initialize HMAC".to_string(),
                })?;

            if last_tag_end == 0 {
                hmac_put_header(file, &mut mac)?;
            }

            let mut q = if last_tag_end == 0 {
                header_size
            } else {
                last_tag_end
            };
            while q <= p {
                let qh = read_object_header(file, q)?;
                hmac_put_object(file, &mut mac, q, &qh)?;
                let adv = align64(qh.size)?;
                q = checked_add_u64(q, adv, "verify-seal next object")?;
            }

            let digest = mac.finalize().into_bytes();
            if digest.as_slice() != tag.tag.as_slice() {
                return Err(SdJournalError::Corrupt {
                    path: Some(file.path().to_path_buf()),
                    offset: Some(p),
                    reason: "tag failed verification".to_string(),
                });
            }

            last_tag_end = checked_add_u64(p, align64(oh.size)?, "verify-seal tag end")?;
            last_epoch = tag.epoch;
            n_tags = n_tags.saturating_add(1);
        }

        p = checked_add_u64(p, align64(oh.size)?, "verify-seal advance")?;
    }

    if n_tags == 0 {
        return Err(SdJournalError::Corrupt {
            path: Some(file.path().to_path_buf()),
            offset: Some(136),
            reason: "sealed journal contains no TAG objects".to_string(),
        });
    }

    Ok(())
}

fn read_object_header(file: &JournalFile, offset: u64) -> Result<ObjectHeader> {
    let buf = file.read_bytes(offset, 16)?;
    let oh = ObjectHeader::parse(buf.as_slice(), file.path(), offset)?;

    if oh.size < 16 {
        return Err(SdJournalError::Corrupt {
            path: Some(file.path().to_path_buf()),
            offset: Some(offset),
            reason: format!("object size too small: {}", oh.size),
        });
    }
    if oh.size > file.config().max_object_size_bytes {
        return Err(SdJournalError::LimitExceeded {
            kind: LimitKind::ObjectSizeBytes,
            limit: file.config().max_object_size_bytes,
        });
    }
    if oh.size % 8 != 0 {
        return Err(SdJournalError::Corrupt {
            path: Some(file.path().to_path_buf()),
            offset: Some(offset),
            reason: format!("object size is not 8-byte aligned: {}", oh.size),
        });
    }

    Ok(oh)
}

fn hmac_put_header(file: &JournalFile, mac: &mut HmacSha256) -> Result<()> {
    let header_bytes = file.read_bytes(0, 136)?;
    let b = header_bytes.as_slice();

    mac.update(b.get(0..16).ok_or_else(|| SdJournalError::Corrupt {
        path: Some(file.path().to_path_buf()),
        offset: Some(0),
        reason: "header too short for sealing verification".to_string(),
    })?);
    mac.update(b.get(24..56).ok_or_else(|| SdJournalError::Corrupt {
        path: Some(file.path().to_path_buf()),
        offset: Some(24),
        reason: "header too short for sealing verification".to_string(),
    })?);
    mac.update(b.get(72..96).ok_or_else(|| SdJournalError::Corrupt {
        path: Some(file.path().to_path_buf()),
        offset: Some(72),
        reason: "header too short for sealing verification".to_string(),
    })?);
    mac.update(b.get(104..136).ok_or_else(|| SdJournalError::Corrupt {
        path: Some(file.path().to_path_buf()),
        offset: Some(104),
        reason: "header too short for sealing verification".to_string(),
    })?);

    Ok(())
}

fn hmac_put_object(
    file: &JournalFile,
    mac: &mut HmacSha256,
    offset: u64,
    oh: &ObjectHeader,
) -> Result<()> {
    hmac_update_range(file, mac, offset, 16)?;

    match oh.object_type {
        OBJECT_DATA => {
            hmac_update_range(file, mac, checked_add_u64(offset, 16, "data.hash")?, 8)?;

            let payload_offset = if file.header().is_compact() {
                72u64
            } else {
                64u64
            };
            if oh.size < payload_offset {
                return Err(SdJournalError::Corrupt {
                    path: Some(file.path().to_path_buf()),
                    offset: Some(offset),
                    reason: format!("DATA object too small: {}", oh.size),
                });
            }

            let payload_len = oh.size - payload_offset;
            hmac_update_range(
                file,
                mac,
                checked_add_u64(offset, payload_offset, "data.payload")?,
                payload_len,
            )?;
        }
        OBJECT_FIELD => {
            const PAYLOAD_OFFSET: u64 = 40;
            hmac_update_range(file, mac, checked_add_u64(offset, 16, "field.hash")?, 8)?;
            if oh.size < PAYLOAD_OFFSET {
                return Err(SdJournalError::Corrupt {
                    path: Some(file.path().to_path_buf()),
                    offset: Some(offset),
                    reason: format!("FIELD object too small: {}", oh.size),
                });
            }
            hmac_update_range(
                file,
                mac,
                checked_add_u64(offset, PAYLOAD_OFFSET, "field.payload")?,
                oh.size - PAYLOAD_OFFSET,
            )?;
        }
        OBJECT_ENTRY => {
            let payload_len = oh
                .size
                .checked_sub(16)
                .ok_or_else(|| SdJournalError::Corrupt {
                    path: Some(file.path().to_path_buf()),
                    offset: Some(offset),
                    reason: "ENTRY object too small".to_string(),
                })?;
            hmac_update_range(
                file,
                mac,
                checked_add_u64(offset, 16, "entry.payload")?,
                payload_len,
            )?;
        }
        OBJECT_DATA_HASH_TABLE | OBJECT_FIELD_HASH_TABLE | OBJECT_ENTRY_ARRAY => {}
        OBJECT_TAG => {
            if oh.size != 64 {
                return Err(SdJournalError::Corrupt {
                    path: Some(file.path().to_path_buf()),
                    offset: Some(offset),
                    reason: format!("TAG object has invalid size: {}", oh.size),
                });
            }
            hmac_update_range(file, mac, checked_add_u64(offset, 16, "tag.seqnum")?, 16)?;
        }
        other => {
            return Err(SdJournalError::Unsupported {
                reason: format!("unsupported object type in sealing verification: {other}"),
            });
        }
    }

    Ok(())
}

fn hmac_update_range(
    file: &JournalFile,
    mac: &mut HmacSha256,
    offset: u64,
    len: u64,
) -> Result<()> {
    let mut off = offset;
    let mut remaining = len;
    while remaining > 0 {
        let take_u64 = std::cmp::min(remaining, VERIFY_READ_CHUNK_SIZE as u64);
        let take = usize::try_from(take_u64).unwrap_or(VERIFY_READ_CHUNK_SIZE);
        let buf = file.read_bytes(off, take)?;
        mac.update(buf.as_slice());
        off = checked_add_u64(off, take_u64, "verify-seal range")?;
        remaining -= take_u64;
    }
    Ok(())
}

#[derive(Debug)]
struct TagObject {
    seqnum: u64,
    epoch: u64,
    tag: [u8; TAG_LENGTH],
}

fn read_tag_object(file: &JournalFile, offset: u64, oh: &ObjectHeader) -> Result<TagObject> {
    if oh.size != 64 {
        return Err(SdJournalError::Corrupt {
            path: Some(file.path().to_path_buf()),
            offset: Some(offset),
            reason: format!("TAG object has invalid size: {}", oh.size),
        });
    }

    let buf = file.read_bytes(offset, 64)?;
    let b = buf.as_slice();
    let seqnum = read_u64_le(b, 16).ok_or_else(|| SdJournalError::Corrupt {
        path: Some(file.path().to_path_buf()),
        offset: Some(offset + 16),
        reason: "TAG.seqnum truncated".to_string(),
    })?;
    let epoch = read_u64_le(b, 24).ok_or_else(|| SdJournalError::Corrupt {
        path: Some(file.path().to_path_buf()),
        offset: Some(offset + 24),
        reason: "TAG.epoch truncated".to_string(),
    })?;
    let tag_bytes = b.get(32..64).ok_or_else(|| SdJournalError::Corrupt {
        path: Some(file.path().to_path_buf()),
        offset: Some(offset + 32),
        reason: "TAG.tag truncated".to_string(),
    })?;
    let mut tag = [0u8; TAG_LENGTH];
    tag.copy_from_slice(tag_bytes);

    Ok(TagObject { seqnum, epoch, tag })
}

fn align64(size: u64) -> Result<u64> {
    let added = checked_add_u64(size, 7, "align64")?;
    Ok(added & !7u64)
}