enprot 0.4.0

Engyon Protected Text (EPT) — confidentiality processor and capability ledger
// Copyright (c) 2018-2026 [Ribose Inc](https://www.ribose.com).
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions
// are met:
// 1. Redistributions of source code must retain the above copyright
//    notice, this list of conditions and the following disclaimer.
// 2. Redistributions in binary form must reproduce the above copyright
//    notice, this list of conditions and the following disclaimer in the
//    documentation and/or other materials provided with the distribution.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
// ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

//! Apply the four transform sets (store / fetch / encrypt / decrypt) to
//! a parsed `TextTree`. `transform` is the entry point; it dispatches
//! per node kind to `transform_begin_end`, `transform_encrypted`, and
//! `transform_stored`. `Plain` and `Data` nodes are passed through
//! unchanged.

use crate::cas;
use crate::error::{Error, Result};
use crate::etree::TextNode;
use crate::etree::TextTree;
use crate::password;
use crate::prot;

pub fn transform(text_in: &TextTree, paops: &mut crate::etree::ParseOps) -> Result<TextTree> {
    if paops.max_depth != 0 && paops.runtime.level > paops.max_depth {
        return Err(Error::Msg("Maximum recursion depth!".into()));
    }
    let mut out = Vec::with_capacity(text_in.len());
    for node in text_in {
        let new_node = match node {
            TextNode::Plain(_) | TextNode::Data(_) => node.clone(),
            // Chain anchors, INCLUDE references, and CONFLICT markers
            // are metadata, not content — pass through unchanged.
            TextNode::Chain { .. } | TextNode::Include { .. } | TextNode::Conflict { .. } => {
                node.clone()
            }
            TextNode::BeginEnd { keyw, txt } => transform_begin_end(keyw, txt, paops)?,
            TextNode::Encrypted {
                keyw,
                txt,
                extfields,
            } => transform_encrypted(keyw, txt, extfields, paops)?,
            TextNode::Stored { keyw, cas } => transform_stored(keyw, cas, paops)?,
        };
        out.push(new_node);
    }
    Ok(out)
}

/// Transform a `BeginEnd` segment. Takes the variant's fields
/// directly (not the whole `TextNode`) so the type system enforces
/// the variant — no runtime check or `unreachable!`.
fn transform_begin_end(
    keyw: &str,
    txt: &TextTree,
    paops: &mut crate::etree::ParseOps,
) -> Result<TextNode> {
    if paops.transforms.encrypt.contains(keyw) {
        paops.runtime.level += 1;
        let block = transform(txt, paops)?;
        paops.runtime.level -= 1;

        let pt = crate::etree::tree_to_blob(&block, paops)?;

        let (ct, extfields) = if !paops.crypto.recipient_pubs.is_empty() {
            // KEM mode (TODO.roadmap/60): encrypt to recipient pubkeys
            // via ML-KEM instead of password-based PBKDF.
            let rng = paops
                .crypto
                .rng
                .as_mut()
                .ok_or(Error::Msg("Missing RNG for KEM encrypt".into()))?;
            crate::kemenc::encrypt(
                pt,
                &paops.crypto.recipient_pubs,
                &paops.crypto.cipheropts.alg,
                rng,
            )?
        } else {
            // Password mode (default).
            let pass = ensure_password(keyw, paops, true);
            prot::encrypt(
                pt,
                &pass,
                &mut paops.crypto.rng,
                &paops.crypto.pbkdfopts,
                &paops.crypto.cipheropts,
                &mut paops.crypto.pbkdf_cache,
                &*paops.crypto.policy,
            )?
        };

        let inner = if paops.transforms.store.contains(keyw) || cas_default_applies(paops) {
            let hexhash = cas::save(ct, paops)?;
            vec![TextNode::Stored {
                keyw: "ct".to_string(),
                cas: hexhash,
            }]
        } else {
            vec![TextNode::Data(ct)]
        };
        return Ok(TextNode::Encrypted {
            keyw: keyw.to_string(),
            txt: inner,
            extfields,
        });
    }

    if paops.transforms.store.contains(keyw) {
        paops.runtime.level += 1;
        let block = transform(txt, paops)?;
        paops.runtime.level -= 1;

        let blob = crate::etree::tree_to_blob(&block, paops)?;
        let hexhash = cas::save(blob, paops)?;
        return Ok(TextNode::Stored {
            keyw: keyw.to_string(),
            cas: hexhash,
        });
    };

    paops.runtime.level += 1;
    let block = transform(txt, paops)?;
    paops.runtime.level -= 1;
    Ok(TextNode::BeginEnd {
        keyw: keyw.to_string(),
        txt: block,
    })
}

/// Transform an `Encrypted` segment. Takes the variant's fields
/// directly; see `transform_begin_end` for rationale.
fn transform_encrypted(
    keyw: &str,
    txt: &TextTree,
    extfields: &std::collections::BTreeMap<String, String>,
    paops: &mut crate::etree::ParseOps,
) -> Result<TextNode> {
    if paops.transforms.decrypt.contains(keyw) {
        let ct = match &txt[0] {
            TextNode::Data(data) => data.clone(),
            TextNode::Stored { cas: hexhash, .. } => cas::load(hexhash, paops)?,
            _ => return Err(Error::Msg("No data in ENCRYPTED.".into())),
        };

        let pt = if extfields.contains_key("recipients") {
            // KEM mode (TODO.roadmap/60): the Encrypted block was
            // encrypted to recipient pubkeys. Look up the matching
            // privkey by WORD, then KEM-decapsulate.
            let priv_pem = paops
                .crypto
                .recipient_privkeys
                .get(keyw)
                .or_else(|| paops.crypto.recipient_privkeys.values().next())
                .ok_or_else(|| {
                    Error::Msg(format!(
                        "KEM-mode block for WORD {} but no --key-file privkey supplied",
                        keyw
                    ))
                })?;
            crate::kemenc::decrypt(&ct, priv_pem, extfields)?
        } else {
            // Password mode (default).
            let pass = ensure_password(keyw, paops, false);
            match prot::decrypt(
                ct,
                &pass,
                &extfields.get("pbkdf"),
                &extfields.get("cipher"),
                &mut paops.crypto.pbkdf_cache,
                &*paops.crypto.policy,
            ) {
                Ok(ct) => ct.to_vec(),
                Err(e) => {
                    eprintln!("Error decrypting {}: {}.", keyw, e);
                    return Err(e);
                }
            }
        };

        let mut block = crate::etree::blob_to_tree(pt, "decrypted".to_string(), paops)?;
        paops.runtime.level += 1;
        block = transform(&block, paops)?;
        paops.runtime.level -= 1;
        return Ok(TextNode::BeginEnd {
            keyw: keyw.to_string(),
            txt: block,
        });
    }

    if paops.transforms.store.contains(keyw) {
        let hexhash = match &txt[0] {
            TextNode::Data(data) => cas::save(data.clone(), paops)?,
            TextNode::Stored { cas: hexhash, .. } => hexhash.clone(),
            _ => return Err(Error::Msg("No data in ENCRYPTED.".into())),
        };
        return Ok(TextNode::Encrypted {
            keyw: keyw.to_string(),
            txt: vec![TextNode::Stored {
                keyw: "ct".to_string(),
                cas: hexhash,
            }],
            extfields: std::collections::BTreeMap::new(),
        });
    }

    if paops.transforms.fetch.contains(keyw) {
        let ct = match &txt[0] {
            TextNode::Data(data) => data.clone(),
            TextNode::Stored { cas: hexhash, .. } => cas::load(hexhash, paops)?,
            _ => return Err(Error::Msg("No data in ENCRYPTED.".into())),
        };
        return Ok(TextNode::Encrypted {
            keyw: keyw.to_string(),
            txt: vec![TextNode::Data(ct)],
            extfields: std::collections::BTreeMap::new(),
        });
    }

    // Caller already destructured; re-construct from references.
    Ok(TextNode::Encrypted {
        keyw: keyw.to_string(),
        txt: txt.clone(),
        extfields: extfields.clone(),
    })
}

/// Transform a `Stored` pointer. Takes the variant's fields directly.
fn transform_stored(keyw: &str, cas: &str, paops: &mut crate::etree::ParseOps) -> Result<TextNode> {
    if paops.transforms.fetch.contains(keyw) {
        let blob = cas::load(cas, paops)?;
        let mut block = crate::etree::blob_to_tree(blob, cas.to_string(), paops)?;
        paops.runtime.level += 1;
        block = transform(&block, paops)?;
        paops.runtime.level -= 1;
        return Ok(TextNode::BeginEnd {
            keyw: keyw.to_string(),
            txt: block,
        });
    }

    Ok(TextNode::Stored {
        keyw: keyw.to_string(),
        cas: cas.to_string(),
    })
}

fn ensure_password(keyw: &str, paops: &mut crate::etree::ParseOps, repeat: bool) -> String {
    if let Some(p) = paops.passwords.get(keyw) {
        return p.clone();
    }
    let p = password::get_password(keyw, repeat);
    paops.passwords.insert(keyw.to_string(), p.clone());
    p
}

/// True when CAS-referenced output should be produced for newly
/// encrypted blocks. Per TODO.roadmap/42: the default is STORED ct
/// unless the caller opted into inline mode (via `--inline`) or no
/// CAS dir was supplied (stdin pipeline carve-out).
fn cas_default_applies(paops: &crate::etree::ParseOps) -> bool {
    !paops.io.inline_data
}