use std::collections::HashMap;
use std::ffi::OsString;
use std::fs;
use std::fs::File;
use std::io::{BufRead, BufReader, BufWriter, Write};
use std::path::{Path, PathBuf};
use clap::builder::PossibleValuesParser;
use clap::{Args, CommandFactory, Parser, Subcommand};
use crate::etree::ParseOps;
use crate::{
Error, Result, capability, cappolicy, cas, cipher, config, consts, crypto, etree, ledger,
merge, output, pbkdf, pki, prot, provenance, resolve, scm,
};
fn make_policy(name: &str) -> Box<dyn crypto::CryptoPolicy> {
match name {
"default" => Box::new(crypto::CryptoPolicyDefault {}),
"nist" => Box::new(crypto::CryptoPolicyNIST {}),
other => panic!(
"make_policy: unknown policy '{}' (callers must validate via VALID_POLICIES)",
other
),
}
}
#[derive(Parser)]
#[command(
name = "enprot",
version,
about = "Engyon Protected Text (EPT) confidentiality processor"
)]
pub struct Cli {
#[command(flatten)]
pub common: CommonArgs,
#[command(subcommand)]
pub command: Command,
}
#[derive(Subcommand)]
pub enum Command {
Encrypt(EncryptSubcmd),
Decrypt(OperationSubcmd),
Store(OperationSubcmd),
Fetch(OperationSubcmd),
EncryptStore(EncryptSubcmd),
Passthrough(OperationSubcmd),
Verify(OperationSubcmd),
List(OperationSubcmd),
Completions {
#[arg(value_enum)]
shell: clap_complete::Shell,
},
Keygen(KeygenSubcmd),
Sign(SignSubcmd),
VerifySig(VerifySigSubcmd),
Fingerprint(FingerprintSubcmd),
VerifyChain(VerifyChainSubcmd),
AuditLog(AuditLogSubcmd),
Snapshot(SnapshotSubcmd),
Pin(PinSubcmd),
Capabilities,
Init(InitSubcmd),
MergeDriver(MergeDriverSubcmd),
Resolve(ResolveSubcmd),
Conflicts(ConflictsSubcmd),
Inspect(InspectSubcmd),
Clean(SmudgeCleanSubcmd),
Smudge(SmudgeCleanSubcmd),
Textconv(SmudgeCleanSubcmd),
Manifest(ManifestSubcmd),
Attest(AttestSubcmd),
Scm(ScmSubcmd),
}
#[derive(Args)]
pub struct InitSubcmd {
#[arg(long)]
pub global: bool,
#[arg(long)]
pub force: bool,
#[arg(long)]
pub git: bool,
}
#[derive(Args)]
pub struct MergeDriverSubcmd {
pub base: PathBuf,
pub ours: PathBuf,
pub theirs: PathBuf,
#[arg(value_name = "PATH")]
pub path: Option<PathBuf>,
}
#[derive(Args)]
pub struct ResolveSubcmd {
#[arg(long, short = 'm', value_name = "MODE", default_value = "interactive")]
pub mode: String,
#[arg(long = "word", value_name = "WORD:MODE", value_delimiter = ',')]
pub word: Vec<String>,
#[arg(value_name = "FILE")]
pub file: PathBuf,
}
#[derive(Args)]
pub struct ConflictsSubcmd {
#[arg(long, value_enum, default_value_t = output::OutputFormat::Text)]
pub format: output::OutputFormat,
#[arg(value_name = "FILE")]
pub file: PathBuf,
}
#[derive(Args)]
pub struct InspectSubcmd {
#[arg(long, value_enum, default_value_t = output::OutputFormat::Text)]
pub format: output::OutputFormat,
#[arg(value_name = "FILE")]
pub file: Option<PathBuf>,
}
#[derive(Args)]
pub struct SmudgeCleanSubcmd {
#[arg(short = 'w', long = "word", value_name = "WORD")]
pub word: String,
#[arg(long, value_name = "ALG")]
pub cipher: Option<String>,
#[arg(long, value_name = "ALG")]
pub pbkdf: Option<String>,
}
#[derive(Args)]
pub struct ManifestSubcmd {
#[arg(value_name = "DIR")]
pub dir: PathBuf,
#[arg(short = 'c', long, value_name = "DIR")]
pub casdir: Option<PathBuf>,
#[arg(short = 'o', long, value_name = "FILE")]
pub output: Option<PathBuf>,
}
#[derive(Args)]
pub struct AttestSubcmd {
#[arg(long, value_name = "PRIV.pem")]
pub signer: PathBuf,
#[arg(value_name = "FILE")]
pub file: PathBuf,
}
#[derive(Args)]
pub struct ScmSubcmd {
#[command(subcommand)]
pub command: ScmCommand,
#[arg(short = 'c', long, global = true)]
pub casdir: Option<PathBuf>,
}
#[derive(Subcommand)]
pub enum ScmCommand {
Init {
#[arg(value_name = "MANIFEST")]
manifest: PathBuf,
},
Add {
#[arg(value_name = "MANIFEST")]
manifest: PathBuf,
#[arg(value_name = "PATH")]
path: PathBuf,
},
Deps {
#[arg(value_name = "MANIFEST")]
manifest: PathBuf,
#[arg(value_name = "Cargo.toml")]
cargo_toml: PathBuf,
},
Attest {
#[arg(long, value_name = "PRIV.pem")]
signer: PathBuf,
#[arg(value_name = "MANIFEST")]
manifest: PathBuf,
},
Verify {
#[arg(long, value_name = "PUB.pem")]
trust_root: PathBuf,
#[arg(value_name = "MANIFEST")]
manifest: PathBuf,
},
Diff {
#[arg(value_name = "OLD")]
old: PathBuf,
#[arg(value_name = "NEW")]
new: PathBuf,
},
}
#[derive(Args)]
pub struct EncryptSubcmd {
#[command(flatten)]
pub encrypt: EncryptOpts,
#[command(flatten)]
pub output: OutputArgs,
#[arg(long = "recipient", value_name = "PUB.pem")]
pub recipients: Vec<PathBuf>,
}
#[derive(Args)]
pub struct OperationSubcmd {
#[command(flatten)]
pub output: OutputArgs,
#[arg(long = "key-file", value_name = "PRIV.pem")]
pub key_files: Vec<PathBuf>,
}
#[derive(Args)]
pub struct KeygenSubcmd {
#[arg(value_parser = clap::builder::PossibleValuesParser::new(
pki::SigAlgKind::ALL.iter().map(|k| k.name()).collect::<Vec<_>>()
))]
pub alg: String,
#[arg(long = "out-priv", value_name = "PATH")]
pub out_priv: Option<PathBuf>,
#[arg(long = "out-pub", value_name = "PATH")]
pub out_pub: Option<PathBuf>,
}
#[derive(Args)]
pub struct SignSubcmd {
#[arg(long, value_parser = clap::builder::PossibleValuesParser::new(
pki::SigAlgKind::ALL.iter().map(|k| k.name()).collect::<Vec<_>>()
))]
pub alg: String,
#[arg(long = "key-file", value_name = "PRIV.pem")]
pub key: Vec<PathBuf>,
#[arg(value_name = "FILE")]
pub input: Option<PathBuf>,
#[arg(short = 'o', long = "out", value_name = "PATH")]
pub out: Option<PathBuf>,
}
#[derive(Args)]
pub struct VerifySigSubcmd {
#[arg(long, value_parser = clap::builder::PossibleValuesParser::new(
pki::SigAlgKind::ALL.iter().map(|k| k.name()).collect::<Vec<_>>()
))]
pub alg: String,
#[arg(long = "key-file", value_name = "PUB.pem")]
pub key: Vec<PathBuf>,
#[arg(long = "sig-file", value_name = "SIG")]
pub sig: Option<PathBuf>,
#[arg(value_name = "FILE")]
pub input: Option<PathBuf>,
}
#[derive(Args)]
pub struct FingerprintSubcmd {
#[arg(value_name = "PUB.pem")]
pub key: PathBuf,
}
#[derive(Args)]
pub struct VerifyChainSubcmd {
#[arg(long = "trust-root", value_name = "PUB.pem")]
pub trust_roots: Vec<PathBuf>,
#[arg(value_name = "FILE")]
pub files: Vec<String>,
}
#[derive(Args)]
pub struct AuditLogSubcmd {
#[arg(long = "signer", value_name = "PRIV.pem")]
pub signer: PathBuf,
#[arg(value_name = "FILE")]
pub file: String,
}
#[derive(Args)]
pub struct SnapshotSubcmd {
#[arg(value_name = "FILE")]
pub file: String,
}
#[derive(Args)]
pub struct PinSubcmd {
#[arg(value_name = "EXPECTED-HASH")]
pub expected: String,
#[arg(value_name = "FILE")]
pub file: String,
}
#[derive(Args, Clone)]
pub struct CommonArgs {
#[arg(short = 'v', long, global = true)]
pub verbose: bool,
#[arg(short = 'q', long, global = true)]
pub quiet: bool,
#[arg(long, global = true, default_value_t = consts::DEFAULT_MAX_DEPTH)]
pub max_depth: usize,
#[arg(short = 'l', long, global = true, default_value = consts::DEFAULT_LEFT_SEP)]
pub left_separator: String,
#[arg(short = 'r', long, global = true, default_value = consts::DEFAULT_RIGHT_SEP)]
pub right_separator: String,
#[arg(short = 'k', long = "key", global = true, value_name = "WORD=PASSWORD", value_parser = parse_word_password)]
pub password: Vec<(String, String)>,
#[arg(short = 'c', long, global = true, value_name = "DIRECTORY", value_parser = parse_casdir)]
pub casdir: Option<PathBuf>,
#[arg(long, global = true, value_parser = PossibleValuesParser::new(consts::VALID_POLICIES.to_vec()))]
pub policy: Option<String>,
#[arg(long, global = true, value_parser = PossibleValuesParser::new(consts::VALID_POLICIES.to_vec()))]
pub defaults: Option<String>,
#[arg(long, global = true)]
pub fips: bool,
#[arg(long, global = true, value_parser = PossibleValuesParser::new(consts::LANG_SEPARATORS.iter().map(|(n, _, _)| *n).collect::<Vec<_>>()))]
pub lang: Option<String>,
#[arg(long = "pbkdf-disable-cache", global = true)]
pub pbkdf_disable_cache: bool,
#[arg(long, global = true)]
pub anchor: bool,
#[arg(long, global = true, value_name = "PRIV.pem")]
pub signer: Option<PathBuf>,
#[arg(long, global = true, value_enum, default_value_t = output::OutputFormat::Text)]
pub format: output::OutputFormat,
#[arg(long, global = true)]
pub inline: bool,
#[arg(long, global = true, value_name = "PATH")]
pub policy_file: Option<PathBuf>,
}
impl CommonArgs {
pub fn for_filter(casdir: Option<PathBuf>) -> Self {
CommonArgs {
verbose: false,
quiet: false,
max_depth: consts::DEFAULT_MAX_DEPTH,
left_separator: consts::DEFAULT_LEFT_SEP.to_string(),
right_separator: consts::DEFAULT_RIGHT_SEP.to_string(),
password: Vec::new(),
casdir,
policy: None,
defaults: None,
fips: false,
lang: None,
pbkdf_disable_cache: false,
anchor: false,
signer: None,
format: output::OutputFormat::Text,
inline: false,
policy_file: None,
}
}
}
#[derive(Args, Default)]
pub struct EncryptOpts {
#[arg(long, value_parser = PossibleValuesParser::new(consts::VALID_PBKDF_ALGS.to_vec()))]
pub pbkdf: Option<String>,
#[arg(long, value_name = "MSEC", value_parser = parse_positive_u32)]
pub pbkdf_msec: Option<u32>,
#[arg(long, value_name = "BYTES", value_parser = parse_positive_usize)]
pub pbkdf_salt_len: Option<usize>,
#[arg(long, value_name = "PARAMS", hide = true)]
pub pbkdf_params: Option<String>,
#[arg(long, value_name = "HEX", hide = true)]
pub pbkdf_salt: Option<String>,
#[arg(long, value_parser = PossibleValuesParser::new(consts::VALID_CIPHER_ALGS.to_vec()))]
pub cipher: Option<String>,
#[arg(long, value_name = "ALG", hide = true)]
pub cipher_iv: Option<String>,
}
#[derive(Args)]
pub struct OutputArgs {
#[arg(short = 'w', long = "word", value_name = "WORD", value_delimiter = ',')]
pub word: Vec<String>,
#[arg(short = 'o', long = "output", value_name = "FILE")]
pub output: Vec<String>,
#[arg(
short = 'p',
long = "prefix",
default_value = "",
allow_hyphen_values = true
)]
pub prefix: String,
#[arg(long, value_name = "DIR", value_parser = parse_output_dir, conflicts_with = "prefix")]
pub output_dir: Option<PathBuf>,
#[arg(value_name = "FILE", default_value = "-")]
pub files: Vec<String>,
}
fn load_pems(paths: &[PathBuf]) -> Result<Vec<String>> {
paths
.iter()
.map(|p| fs::read_to_string(p).map_err(Error::from))
.collect()
}
fn load_privkey_pems(paths: &[PathBuf]) -> Result<Vec<String>> {
load_pems(paths)
}
fn parse_word_password(s: &str) -> std::result::Result<(String, String), String> {
let (word, pass) = s
.split_once('=')
.ok_or_else(|| format!("Must be of the form WORD=PASSWORD, got '{}'", s))?;
if word.is_empty() || pass.is_empty() {
return Err(format!("Must be of the form WORD=PASSWORD, got '{}'", s));
}
Ok((word.to_string(), pass.to_string()))
}
fn parse_casdir(s: &str) -> std::result::Result<PathBuf, String> {
let p = PathBuf::from(s);
if p.is_dir() {
Ok(p)
} else {
Err(format!("'{}' is not a directory", s))
}
}
fn parse_output_dir(s: &str) -> std::result::Result<PathBuf, String> {
let p = PathBuf::from(s);
if p.is_dir() {
Ok(p)
} else {
Err(format!("--output-dir '{}' is not a directory", s))
}
}
fn parse_positive_u32(s: &str) -> std::result::Result<u32, String> {
let n: u32 = s
.parse()
.map_err(|_| format!("expected a number > 0, got '{}'", s))?;
if n == 0 {
return Err(format!("expected a number > 0, got '{}'", s));
}
Ok(n)
}
fn parse_positive_usize(s: &str) -> std::result::Result<usize, String> {
let n: usize = s
.parse()
.map_err(|_| format!("expected a number > 0, got '{}'", s))?;
if n == 0 {
return Err(format!("expected a number > 0, got '{}'", s));
}
Ok(n)
}
pub fn app_main<I, T>(args: I) -> Result<()>
where
I: IntoIterator<Item = T>,
T: Into<OsString> + Clone,
{
let cli = Cli::parse_from(args);
if let Command::Init(a) = cli.command {
return init_config(a);
}
if let Command::MergeDriver(a) = cli.command {
return run_merge_driver(a);
}
if let Command::Resolve(a) = cli.command {
return run_resolve(a);
}
if let Command::Conflicts(a) = cli.command {
return run_conflicts(a);
}
if let Command::Inspect(a) = cli.command {
return run_inspect(a, cli.common.clone());
}
if let Command::Clean(a) = cli.command {
return run_smudge_clean(SmudgeMode::Clean, a, cli.common);
}
if let Command::Smudge(a) = cli.command {
return run_smudge_clean(SmudgeMode::Smudge, a, cli.common);
}
if let Command::Textconv(a) = cli.command {
return run_smudge_clean(SmudgeMode::Smudge, a, cli.common);
}
if let Command::Manifest(a) = cli.command {
return run_manifest(a);
}
if let Command::Attest(a) = cli.command {
return run_attest(a);
}
if let Command::Scm(a) = cli.command {
return run_scm(a);
}
let mut common = cli.common;
common = apply_config(common)?;
match cli.command {
Command::Encrypt(a) => run(
common,
a.output,
Some((a.encrypt, Operation::Encrypt)),
load_pems(&a.recipients)?,
Vec::new(),
),
Command::Decrypt(a) => run(
common,
a.output,
Some((EncryptOpts::default(), Operation::Decrypt)),
Vec::new(),
load_privkey_pems(&a.key_files)?,
),
Command::Store(a) => run(
common,
a.output,
Some((EncryptOpts::default(), Operation::Store)),
Vec::new(),
Vec::new(),
),
Command::Fetch(a) => run(
common,
a.output,
Some((EncryptOpts::default(), Operation::Fetch)),
Vec::new(),
Vec::new(),
),
Command::EncryptStore(a) => run(
common,
a.output,
Some((a.encrypt, Operation::EncryptStore)),
load_pems(&a.recipients)?,
Vec::new(),
),
Command::Passthrough(a) => run(common, a.output, None, Vec::new(), Vec::new()),
Command::Verify(a) => verify_files(common, a.output),
Command::List(a) => list_files(common, a.output),
Command::Completions { shell } => {
clap_complete::generate(shell, &mut Cli::command(), "enprot", &mut std::io::stdout());
Ok(())
}
Command::Keygen(a) => pki_keygen(common, a),
Command::Sign(a) => pki_sign(common, a),
Command::VerifySig(a) => pki_verify_sig(common, a),
Command::Fingerprint(a) => pki_fingerprint(a),
Command::VerifyChain(a) => verify_chain_files(common, a),
Command::AuditLog(a) => audit_log_stream(common, a),
Command::Snapshot(a) => snapshot_file(a),
Command::Pin(a) => pin_file(a),
Command::Capabilities => {
let policy = resolve_policy(&common)?;
let mut paops = ParseOps::new(policy)?;
apply_common(&common, &mut paops);
let caps = capability::CapabilitySet::from_paops(&paops);
let stdout = std::io::stdout();
let mut out = stdout.lock();
match common.format {
output::OutputFormat::Text => {
for c in caps.iter_sorted() {
writeln!(out, "{}", c)?;
}
}
output::OutputFormat::Json => {
let dtos = caps
.iter_sorted()
.into_iter()
.map(capability_to_dto)
.collect::<Vec<_>>();
let payload = output::CapabilitiesOutput { capabilities: dtos };
writeln!(out, "{}", output::to_json(&payload)?)?;
}
}
Ok(())
}
Command::Init(_) => unreachable!("dispatched at top of app_main"),
Command::MergeDriver(_) => unreachable!("dispatched at top of app_main"),
Command::Resolve(_) => unreachable!("dispatched at top of app_main"),
Command::Conflicts(_) => unreachable!("dispatched at top of app_main"),
Command::Inspect(_) => unreachable!("dispatched at top of app_main"),
Command::Clean(_) | Command::Smudge(_) | Command::Textconv(_) => {
unreachable!("dispatched at top of app_main")
}
Command::Manifest(_) | Command::Attest(_) => {
unreachable!("dispatched at top of app_main")
}
Command::Scm(_) => unreachable!("dispatched at top of app_main"),
}
}
fn apply_config(mut common: CommonArgs) -> Result<CommonArgs> {
let cfg = config::Config::load(&PathBuf::from("."))?;
if common.casdir.is_none() {
common.casdir = cfg.casdir.clone();
}
if common.policy.is_none() {
common.policy = cfg.policy.clone();
}
if common.defaults.is_none() {
common.defaults = cfg.defaults.clone();
}
if common.lang.is_none() {
common.lang = cfg.lang.clone();
}
if common.signer.is_none() {
common.signer = cfg.chain.signer.as_ref().map(PathBuf::from);
}
if cfg.chain.auto_anchor == Some(true) {
common.anchor = true;
}
if cfg.fips == Some(true) {
common.fips = true;
}
Ok(common)
}
fn init_config(a: InitSubcmd) -> Result<()> {
let target = if a.global {
config::user_config_path()
.ok_or_else(|| Error::msg("could not resolve user config path (is $HOME set?)"))?
} else {
PathBuf::from(".enprot.toml")
};
if target.exists() && !a.force {
return Err(Error::msg(format!(
"{} already exists; pass --force to overwrite",
target.display()
)));
}
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&target, config::Config::template())?;
println!("wrote {}", target.display());
if a.git {
init_gitattributes()?;
}
let cas_path = PathBuf::from("cas");
if !cas_path.exists() {
std::fs::create_dir(&cas_path)?;
eprintln!("created {}", cas_path.display());
}
Ok(())
}
fn init_gitattributes() -> Result<()> {
let path = PathBuf::from(".gitattributes");
let snippet = "# Route *.ept through enprot's clean/smudge filters.\n\
*.ept filter=enprot diff=enprot merge=enprot\n";
if path.exists() {
return Err(Error::msg(format!(
"{} already exists; merge this snippet in manually:\n{}",
path.display(),
snippet
)));
}
std::fs::write(&path, snippet)?;
println!("wrote {}", path.display());
println!(
"\nAdd the following to .git/config (or run `git config -f .git/config ...`):\n\
[filter \"enprot\"]\n\
\x20 clean = enprot clean -w WORD -k WORD=PASSWORD\n\
\x20 smudge = enprot smudge -w WORD -k WORD=PASSWORD\n\
[diff \"enprot\"]\n\
\x20 textconv = enprot textconv -w WORD -k WORD=PASSWORD\n\
[merge \"enprot\"]\n\
\x20 driver = enprot merge-driver %O %A %B %P\n"
);
Ok(())
}
fn run_merge_driver(a: MergeDriverSubcmd) -> Result<()> {
let conflicts = merge::merge_paths(&a.base, &a.ours, &a.theirs)?;
if conflicts > 0 {
eprintln!(
"merge-driver: {} conflict(s) emitted in {}",
conflicts,
a.ours.display()
);
}
Ok(())
}
fn run_resolve(a: ResolveSubcmd) -> Result<()> {
use std::io::{BufReader, BufWriter, IsTerminal};
let mode = resolve::ResolveMode::from_cli_flag(&a.mode)?;
let overrides = resolve::WordOverride::from_cli_flags(&a.word)?;
if matches!(mode, resolve::ResolveMode::Interactive) && !std::io::stdin().is_terminal() {
return Err(Error::msg(
"resolve --interactive requires a TTY (pass --mode ours/theirs/both/skip for non-interactive runs)",
));
};
let policy = Box::new(crypto::CryptoPolicyDefault {}) as Box<dyn crypto::CryptoPolicy>;
let mut paops = ParseOps::new(policy)?;
paops.runtime.fname = a.file.display().to_string();
let f = File::open(&a.file)?;
let tree = etree::parse(BufReader::new(f), &mut paops)?;
let (resolved, n) = resolve::resolve_tree_with_overrides(&tree, mode, &overrides)?;
let out = File::create(&a.file)?;
etree::tree_write(&mut BufWriter::new(out), &resolved, &mut paops)?;
eprintln!("resolve: cleared {} conflict(s) in {}", n, a.file.display());
Ok(())
}
fn run_conflicts(a: ConflictsSubcmd) -> Result<()> {
use std::io::BufReader;
let policy = Box::new(crypto::CryptoPolicyDefault {}) as Box<dyn crypto::CryptoPolicy>;
let mut paops = ParseOps::new(policy)?;
paops.runtime.fname = a.file.display().to_string();
let f = File::open(&a.file)?;
let tree = etree::parse(BufReader::new(f), &mut paops)?;
let entries: Vec<output::ConflictEntry> = tree
.iter()
.filter_map(|n| match n {
etree::TextNode::Conflict { keyw, ours, theirs } => Some(output::ConflictEntry {
word: keyw.clone(),
ours_nodes: ours.len(),
theirs_nodes: theirs.len(),
}),
_ => None,
})
.collect();
let count = entries.len();
match a.format {
output::OutputFormat::Text => {
if entries.is_empty() {
println!("no conflicts in {}", a.file.display());
} else {
for e in &entries {
println!(
"{:<16} {} nodes ours / {} nodes theirs",
e.word, e.ours_nodes, e.theirs_nodes
);
}
}
}
output::OutputFormat::Json => {
let payload = output::ConflictsOutput { conflicts: entries };
println!("{}", output::to_json(&payload)?);
}
}
if count > 0 {
std::process::exit(1);
}
Ok(())
}
fn run_inspect(a: InspectSubcmd, common: CommonArgs) -> Result<()> {
let policy = resolve_policy(&common)?;
let mut paops = ParseOps::new(policy)?;
apply_common(&common, &mut paops);
let reader: Box<dyn BufRead> = match &a.file {
Some(p) if p != &PathBuf::from("-") => {
let path_str = p.display().to_string();
paops.runtime.fname = path_str.clone();
Box::new(BufReader::new(File::open(p).map_err(|e| {
Error::msg(format!("inspect: failed to open {}: {}", path_str, e))
})?))
}
_ => {
paops.runtime.fname = "<stdin>".into();
Box::new(BufReader::new(std::io::stdin()))
}
};
let tree = etree::parse(reader, &mut paops)?;
println!("== structure ==");
let stdout = std::io::stdout();
list_tree(&tree, 0, &mut stdout.lock())?;
let mut dag = ledger::AnchorDag::new();
collect_chain_anchors(&tree, &mut dag)?;
println!("\n== chain anchors ==");
if dag.is_empty() {
println!(" (none)");
} else {
println!(" {} anchor(s)", dag.len());
for (id, signed) in dag.iter() {
println!(" {} signer={}", id.to_hex(), signed.anchor.signer);
}
}
let conflict_count = tree
.iter()
.filter(|n| matches!(n, etree::TextNode::Conflict { .. }))
.count();
println!("\n== conflicts ==");
if conflict_count == 0 {
println!(" (none)");
} else {
println!(" {} unresolved conflict(s)", conflict_count);
}
let caps = capability::CapabilitySet::from_paops(&paops);
println!("\n== capabilities ==");
for c in caps.iter_sorted() {
println!(" {}", c);
}
if conflict_count > 0 {
std::process::exit(1);
}
Ok(())
}
#[derive(Copy, Clone, Eq, PartialEq)]
enum SmudgeMode {
Clean,
Smudge,
}
fn run_manifest(a: ManifestSubcmd) -> Result<()> {
let casdir = a
.casdir
.clone()
.or_else(|| {
if Path::new("cas").is_dir() {
Some(PathBuf::from("cas"))
} else {
Some(PathBuf::from("."))
}
})
.unwrap();
if !casdir.is_dir() {
std::fs::create_dir_all(&casdir)?;
}
eprintln!("manifest: walking {}...", a.dir.display());
let tree = provenance::build_manifest(&a.dir, &casdir)?;
eprintln!(
"manifest: {} entries",
tree.iter()
.filter(|n| matches!(n, etree::TextNode::Include { .. }))
.count()
);
let policy = crypto::default_policy();
let mut paops = ParseOps::new(policy)?;
paops.runtime.fname = a
.output
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "<stdout>".into());
match a.output.as_ref() {
Some(path) => {
let f = File::create(path)?;
etree::tree_write(&mut BufWriter::new(f), &tree, &mut paops)?;
eprintln!("manifest: wrote {}", path.display());
}
None => {
let stdout = std::io::stdout();
etree::tree_write(&mut stdout.lock(), &tree, &mut paops)?;
}
}
Ok(())
}
fn run_attest(a: AttestSubcmd) -> Result<()> {
let priv_pem = fs::read_to_string(&a.signer)?;
let body = fs::read_to_string(&a.file)?;
let policy = Box::new(crypto::CryptoPolicyDefault {}) as Box<dyn crypto::CryptoPolicy>;
let mut paops = ParseOps::new(policy)?;
paops.runtime.fname = a.file.display().to_string();
let cursor = std::io::Cursor::new(body.into_bytes());
let tree = etree::parse(cursor, &mut paops)?;
if paops.io.casdir.as_os_str().is_empty() {
paops.io.casdir = PathBuf::from(".");
}
let attested = provenance::attest(&tree, &priv_pem, &paops.io.casdir, Vec::new())?;
let f = File::create(&a.file)?;
etree::tree_write(&mut BufWriter::new(f), &attested, &mut paops)?;
eprintln!("attest: signed {}", a.file.display());
Ok(())
}
fn run_scm(a: ScmSubcmd) -> Result<()> {
let casdir = a.casdir.clone().unwrap_or_else(|| {
if Path::new("cas").is_dir() {
PathBuf::from("cas")
} else {
PathBuf::from(".")
}
});
if !casdir.is_dir() {
std::fs::create_dir_all(&casdir)?;
}
match a.command {
ScmCommand::Init { manifest } => {
scm::init_manifest(&manifest)?;
println!("scm init: wrote {}", manifest.display());
Ok(())
}
ScmCommand::Add { manifest, path } => {
let n = scm::add_to_manifest(&manifest, &path, &casdir)?;
println!("scm add: appended {} entries to {}", n, manifest.display());
Ok(())
}
ScmCommand::Deps {
manifest,
cargo_toml,
} => {
let n = scm::add_cargo_deps(&manifest, &cargo_toml, &casdir)?;
println!("scm deps: appended {} entries to {}", n, manifest.display());
Ok(())
}
ScmCommand::Attest { signer, manifest } => {
run_attest(AttestSubcmd {
signer,
file: manifest.clone(),
})?;
println!("scm attest: signed {}", manifest.display());
Ok(())
}
ScmCommand::Verify {
trust_root,
manifest,
} => {
verify_chain_files(
CommonArgs::for_filter(Some(casdir.clone())),
VerifyChainSubcmd {
trust_roots: vec![trust_root],
files: vec![manifest.to_string_lossy().into_owned()],
},
)
}
ScmCommand::Diff { old, new } => {
let d = scm::diff_manifests(&old, &new)?;
print!("{d}");
Ok(())
}
}
}
fn run_smudge_clean(mode: SmudgeMode, a: SmudgeCleanSubcmd, common: CommonArgs) -> Result<()> {
use std::io::{Read, Write};
let password = lookup_word_password(&common, &a.word)?;
let mut input = Vec::new();
std::io::stdin().read_to_end(&mut input)?;
let stdout = std::io::stdout();
let mut out = stdout.lock();
let policy = Box::new(crypto::CryptoPolicyDefault {}) as Box<dyn crypto::CryptoPolicy>;
let mut paops = ParseOps::new(policy)?;
paops.passwords.insert(a.word.clone(), password);
match mode {
SmudgeMode::Clean => {
paops.crypto.cipheropts.alg = a
.cipher
.clone()
.unwrap_or_else(|| "aes-256-gcm-siv-det".to_string());
if let Some(p) = a.pbkdf.as_ref() {
paops.crypto.pbkdfopts.alg = p.clone();
}
let (ct, extfields) = prot::encrypt(
input,
paops.passwords.get(&a.word).unwrap(),
&mut paops.crypto.rng,
&paops.crypto.pbkdfopts,
&paops.crypto.cipheropts,
&mut paops.crypto.pbkdf_cache,
&*paops.crypto.policy,
)?;
let tree: etree::TextTree = vec![etree::TextNode::Encrypted {
keyw: a.word.clone(),
txt: vec![etree::TextNode::Data(ct)],
extfields,
}];
etree::tree_write(&mut out, &tree, &mut paops)?;
}
SmudgeMode::Smudge => {
paops.runtime.fname = "<smudge-stdin>".into();
let cursor = std::io::Cursor::new(input);
let tree = etree::parse(cursor, &mut paops)?;
let (ct, pbkdf, cipher) = extract_first_encrypted(&tree, &a.word)
.ok_or_else(|| Error::msg(format!("no ENCRYPTED {} block in input", a.word)))?;
let pt = prot::decrypt(
ct,
paops.passwords.get(&a.word).unwrap(),
&pbkdf.as_ref(),
&cipher.as_ref(),
&mut paops.crypto.pbkdf_cache,
&*paops.crypto.policy,
)?;
out.write_all(&pt)?;
}
}
out.flush()?;
Ok(())
}
fn extract_first_encrypted(
tree: &etree::TextTree,
word: &str,
) -> Option<(Vec<u8>, Option<String>, Option<String>)> {
for node in tree {
match node {
etree::TextNode::Encrypted {
keyw,
txt,
extfields,
} if keyw == word => {
if let Some(first) = txt.first() {
let payload = match first {
etree::TextNode::Data(d) => Some(d.clone()),
_ => None,
};
if let Some(ct) = payload {
let pbkdf = extfields.get("pbkdf").cloned();
let cipher = extfields.get("cipher").cloned();
return Some((ct, pbkdf, cipher));
}
}
}
etree::TextNode::BeginEnd { txt, .. } => {
if let Some(found) = extract_first_encrypted(txt, word) {
return Some(found);
}
}
_ => {}
}
}
None
}
fn lookup_word_password(common: &CommonArgs, word: &str) -> Result<String> {
for (w, p) in &common.password {
if w == word {
return Ok(p.clone());
}
}
if let Ok(env_val) = std::env::var("ENPROPT_KEY") {
if let Some((w, p)) = env_val.split_once('=') {
if w == word && !p.is_empty() {
return Ok(p.to_string());
}
}
}
Err(Error::msg(format!(
"no password supplied for WORD '{}' (pass `-k {0}=PASSWORD` or set ENPROPT_KEY={0}=PASSWORD)",
word
)))
}
#[derive(Copy, Clone)]
enum Operation {
Encrypt,
Decrypt,
Store,
Fetch,
EncryptStore,
}
impl Operation {
fn label(self) -> &'static str {
match self {
Operation::Encrypt => "encrypt",
Operation::Decrypt => "decrypt",
Operation::Store => "store",
Operation::Fetch => "fetch",
Operation::EncryptStore => "encrypt-store",
}
}
}
fn run(
common: CommonArgs,
output: OutputArgs,
op: Option<(EncryptOpts, Operation)>,
recipient_pubs: Vec<String>,
recipient_privs: Vec<String>,
) -> Result<()> {
let explicit_policy = common.policy.clone();
let mut policy_name = explicit_policy
.clone()
.unwrap_or_else(|| consts::DEFAULT_POLICY.to_string());
let fips = common.fips
|| (cfg!(unix)
&& match fs::read_to_string("/proc/sys/crypto/fips_enabled") {
Ok(s) => s.starts_with('1'),
Err(_) => false,
});
if fips {
if let Some(p) = explicit_policy.as_deref() {
if p != "nist" {
return Err(Error::Msg(format!(
"Policy setting of '{}' conflicts with --fips",
p
)));
}
}
policy_name = "nist".to_string();
}
let policy = make_policy(&policy_name);
let mut paops = if let Some(defaults) = common.defaults.as_deref() {
let mut p = ParseOps::new(make_policy(defaults))?;
p.crypto.policy = policy;
p
} else {
ParseOps::new(policy)?
};
if let Some(dir) = common.casdir.clone() {
paops.io.casdir = dir;
} else if Path::new("cas").is_dir() {
paops.io.casdir = Path::new("cas").to_path_buf();
} else {
paops.io.casdir = Path::new(".").to_path_buf();
}
paops.io.verbose = common.verbose && !common.quiet;
paops.io.inline_data = common.inline || common.casdir.is_none();
paops.max_depth = common.max_depth;
let (left, right) = resolve_separators(&common);
paops.separators.left = left;
paops.separators.right = right;
paops.passwords.extend(common.password);
paops.crypto.recipient_pubs = recipient_pubs;
for (i, w) in output.word.iter().enumerate() {
if let Some(priv_pem) = recipient_privs.get(i).or_else(|| recipient_privs.first()) {
paops
.crypto
.recipient_privkeys
.insert(w.clone(), priv_pem.clone());
}
}
if common.pbkdf_disable_cache {
paops.crypto.pbkdf_cache = None;
}
if let Some((enc_opts, op_kind)) = op.as_ref() {
if matches!(op_kind, Operation::Encrypt | Operation::EncryptStore) {
if let Some(p) = common
.policy_file
.as_ref()
.map(|p| cappolicy::CapPolicy::load_file(p))
.transpose()?
{
let held = capability::CapabilitySet::from_paops(&paops);
for w in &output.word {
p.check_word_capability(w, &held)?;
}
}
}
for w in &output.word {
match op_kind {
Operation::Encrypt => {
paops.transforms.encrypt.insert(w.clone());
}
Operation::Decrypt => {
paops.transforms.decrypt.insert(w.clone());
}
Operation::Store => {
paops.transforms.store.insert(w.clone());
}
Operation::Fetch => {
paops.transforms.fetch.insert(w.clone());
}
Operation::EncryptStore => {
paops.transforms.encrypt.insert(w.clone());
paops.transforms.store.insert(w.clone());
}
}
}
if matches!(op_kind, Operation::Encrypt | Operation::EncryptStore) {
if let Some(alg) = enc_opts.pbkdf.as_deref() {
paops.crypto.pbkdfopts.alg = alg.to_string();
}
if let Some(saltlen) = enc_opts.pbkdf_salt_len {
paops.crypto.pbkdfopts.saltlen = saltlen;
}
if let Some(msec) = enc_opts.pbkdf_msec {
paops.crypto.pbkdfopts.msec = Some(msec);
}
if let Some(raw) = enc_opts.pbkdf_params.as_deref() {
paops.crypto.pbkdfopts.msec = None;
let params: std::collections::BTreeMap<String, usize> = raw
.split(',')
.map(|kv| {
let (k, v) = kv.split_once('=').unwrap_or(("", "0"));
(k.to_string(), v.parse().unwrap_or(0))
})
.collect();
paops.crypto.pbkdfopts.params = Some(params);
}
if let Some(salt_hex) = enc_opts.pbkdf_salt.as_deref() {
paops.crypto.pbkdfopts.salt = Some(hex::decode(salt_hex).map_err(Error::from)?);
}
if let Some(c) = enc_opts.cipher.as_deref() {
paops.crypto.cipheropts.alg = c.to_string();
}
if let Some(iv_hex) = enc_opts.cipher_iv.as_deref() {
paops.crypto.cipheropts.iv = Some(hex::decode(iv_hex).map_err(Error::from)?);
}
}
}
if paops.io.verbose {
eprintln!(
"LEFT_SEP='{}' RIGHT_SEP='{}' casdir = '{}'",
paops.separators.left,
paops.separators.right,
paops.io.casdir.display(),
);
}
let files = pair_inputs_to_outputs(
&output.files,
&output.output,
&output.prefix,
output.output_dir.as_deref(),
);
paops.anchor = build_anchor_config(
common.anchor,
common.signer.as_deref(),
op.as_ref().map(|(_, k)| *k),
&output.word,
)?;
for (path_in, path_out) in files {
process_one_file(&path_in, &path_out, &mut paops)?;
}
Ok(())
}
fn pair_inputs_to_outputs(
inputs: &[String],
outputs: &[String],
prefix: &str,
output_dir: Option<&Path>,
) -> Vec<(String, String)> {
let mut result = Vec::with_capacity(inputs.len());
let mut out_iter = outputs.iter();
let prefix_is_dir = !prefix.is_empty() && (prefix.ends_with('/') || Path::new(prefix).is_dir());
for input in inputs {
if let Some(output) = out_iter.next() {
result.push((input.clone(), output.clone()));
continue;
}
let output = if input == "-" {
"-".to_string()
} else if let Some(dir) = output_dir {
join_with_basename(dir, input)
} else if prefix_is_dir {
let dir_str = prefix.trim_end_matches('/');
join_with_basename(Path::new(dir_str), input)
} else if prefix.is_empty() {
input.clone()
} else {
format!("{}{}", prefix, input)
};
result.push((input.clone(), output));
}
result
}
fn join_with_basename(dir: &Path, input: &str) -> String {
let base = Path::new(input)
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| input.to_string());
dir.join(base).to_string_lossy().into_owned()
}
fn build_anchor_config(
anchor_flag: bool,
signer_path: Option<&Path>,
op_kind: Option<Operation>,
words: &[String],
) -> Result<etree::AnchorConfig> {
if !anchor_flag {
return Ok(etree::AnchorConfig::disabled());
}
let signer_path =
signer_path.ok_or_else(|| Error::msg("--anchor requires --signer <PRIV.pem>"))?;
let priv_pem = fs::read_to_string(signer_path)?;
let op = op_kind
.map(|k| k.label())
.unwrap_or("passthrough")
.to_string();
Ok(etree::AnchorConfig {
enabled: true,
operation: op,
words: words.to_vec(),
signer_priv_pem: Some(priv_pem),
})
}
fn process_one_file(path_in: &str, path_out: &str, paops: &mut ParseOps) -> Result<()> {
if paops.io.verbose {
eprintln!("Reading {}", path_in);
}
let reader_in: Box<dyn BufRead> = if path_in == "-" {
Box::new(BufReader::new(std::io::stdin()))
} else {
match File::open(path_in) {
Ok(f) => Box::new(BufReader::new(f)),
Err(e) => {
return Err(Error::Msg(format!(
"Failed to open {} for reading: {}",
path_in, e
)));
}
}
};
paops.runtime.fname = if path_in == "-" {
"<stdin>".to_string()
} else {
path_in.to_string()
};
let tree_in = etree::parse(reader_in, paops)
.map_err(|e| Error::Msg(format!("{} in {}, aborting.", e, path_in)))?;
if paops.io.verbose {
eprintln!("Transforming {}", path_in);
}
let mut tree_out = etree::transform(&tree_in, paops)
.map_err(|e| Error::Msg(format!("{} in {}, aborting.", e, path_in)))?;
if paops.anchor.enabled {
let chain_node = build_chain_anchor_node(&tree_out, paops)?;
tree_out.push(chain_node);
}
if paops.io.verbose {
eprintln!("Writing {}", path_out);
}
let mut writer_out: Box<dyn Write> = if path_out == "-" {
Box::new(BufWriter::new(std::io::stdout()))
} else {
match File::create(path_out) {
Ok(f) => Box::new(BufWriter::new(f)),
Err(e) => {
return Err(Error::Msg(format!(
"Failed to open {} for writing: {}",
path_out, e
)));
}
}
};
etree::tree_write(&mut writer_out, &tree_out, paops)
.map_err(|e| Error::Msg(format!("Write to {} failed: {}", path_out, e)))?;
Ok(())
}
fn build_chain_anchor_node(
tree_out: &etree::TextTree,
paops: &mut ParseOps,
) -> Result<etree::TextNode> {
use crate::ledger::{Anchor, PayloadHash, SignerId};
use crate::pki::SigAlgKind;
use std::collections::BTreeMap;
let priv_pem = paops
.anchor
.signer_priv_pem
.clone()
.ok_or_else(|| Error::msg("anchor config missing signer_priv_pem"))?;
let botan_priv = botan::Privkey::load_pem(&priv_pem).map_err(Error::botan)?;
let botan_pub = botan_priv.pubkey().map_err(Error::botan)?;
let pub_pem = botan_pub.pem_encode().map_err(Error::botan)?;
let fp = capability::KeyFp::from_pem(&pub_pem)?;
let blob = etree::tree_to_blob(tree_out, paops)?;
let policy = crate::crypto::CryptoPolicyDefault {};
let payload_hex = crate::crypto::hexdigest("sha3-256", &blob, &policy)?;
let mut payload_arr = [0u8; 32];
payload_arr.copy_from_slice(&hex::decode(payload_hex)?);
let payload_hash = PayloadHash(payload_arr);
let parents = latest_existing_anchors(tree_out, paops)?;
let words_joined = paops.anchor.words.join(",");
let mutations = if words_joined.is_empty() {
paops.anchor.operation.clone()
} else {
format!("{}+{}", paops.anchor.operation, words_joined)
};
let signer = SignerId::new(SigAlgKind::Ed25519, fp);
let anchor = Anchor::builder(signer, payload_hash)
.with_parents(parents)
.with_mutations(mutations)
.build();
let signed = anchor.sign(&priv_pem, &pub_pem, SigAlgKind::Ed25519)?;
let extfields: BTreeMap<String, String> = signed.to_extfields();
Ok(etree::TextNode::Chain { extfields })
}
fn latest_existing_anchors(
tree: &etree::TextTree,
_paops: &ParseOps,
) -> Result<Vec<crate::ledger::AnchorHash>> {
let mut all = Vec::new();
walk_for_chains(tree, &mut all)?;
Ok(all.pop().into_iter().collect())
}
fn walk_for_chains(tree: &etree::TextTree, out: &mut Vec<crate::ledger::AnchorHash>) -> Result<()> {
for node in tree {
match node {
etree::TextNode::Chain { extfields } => {
let signed = crate::ledger::SignedAnchor::from_extfields(extfields)?;
if let Ok(h) = signed.id() {
out.push(h);
}
}
etree::TextNode::BeginEnd { txt, .. } | etree::TextNode::Encrypted { txt, .. } => {
walk_for_chains(txt, out)?;
}
_ => {}
}
}
Ok(())
}
fn capability_to_dto(c: &capability::Capability) -> output::CapabilityDto {
use capability::Capability::*;
match c {
Viewer => output::CapabilityDto {
tier: "viewer",
word: None,
key_fp: None,
},
Reader => output::CapabilityDto {
tier: "reader",
word: None,
key_fp: None,
},
Decryptor(w) => output::CapabilityDto {
tier: "decryptor",
word: Some(w.to_string()),
key_fp: None,
},
Signer(fp) => output::CapabilityDto {
tier: "signer",
word: None,
key_fp: Some(fp.to_hex()),
},
Verifier(fp) => output::CapabilityDto {
tier: "verifier",
word: None,
key_fp: Some(fp.to_hex()),
},
}
}
fn list_files(common: CommonArgs, output_args: OutputArgs) -> Result<()> {
let policy = resolve_policy(&common)?;
let mut paops = ParseOps::new(policy)?;
apply_common(&common, &mut paops);
let files = pair_inputs_to_outputs(
&output_args.files,
&output_args.output,
&output_args.prefix,
output_args.output_dir.as_deref(),
);
let stdout = std::io::stdout();
let mut out = stdout.lock();
let mut json_listings: Vec<output::FileListing> = Vec::new();
for (path_in, _) in &files {
let reader: Box<dyn BufRead> = if path_in == "-" {
Box::new(BufReader::new(std::io::stdin()))
} else {
Box::new(BufReader::new(File::open(path_in).map_err(|e| {
Error::Msg(format!("Failed to open {}: {}", path_in, e))
})?))
};
paops.runtime.fname = path_in.clone();
let tree = etree::parse(reader, &mut paops)?;
match common.format {
output::OutputFormat::Text => {
if files.len() > 1 {
writeln!(out, "== {} ==", path_in)?;
}
list_tree(&tree, 0, &mut out)?;
}
output::OutputFormat::Json => {
let mut nodes = Vec::new();
list_tree_to_nodes(&tree, 0, &mut nodes);
json_listings.push(output::FileListing {
path: path_in.clone(),
nodes,
});
}
}
}
if matches!(common.format, output::OutputFormat::Json) {
let payload = output::ListOutput {
files: json_listings,
};
writeln!(out, "{}", output::to_json(&payload)?)?;
}
Ok(())
}
fn list_tree<W: Write>(tree: &etree::TextTree, depth: usize, out: &mut W) -> Result<()> {
let indent = " ".repeat(depth);
for node in tree {
match node {
etree::TextNode::BeginEnd { keyw, txt } => {
writeln!(out, "{}BEGIN/END {}", indent, keyw)?;
list_tree(txt, depth + 1, out)?;
}
etree::TextNode::Encrypted {
keyw, extfields, ..
} => {
let cipher = extfields
.get("cipher")
.map(|s| s.as_str())
.unwrap_or("aes-256-siv");
let pbkdf = extfields
.get("pbkdf")
.map(|s| s.split('$').nth(1).unwrap_or("?"))
.unwrap_or("legacy");
writeln!(
out,
"{}ENCRYPTED {} cipher={} pbkdf={}",
indent, keyw, cipher, pbkdf
)?;
}
etree::TextNode::Stored { keyw, cas } => {
writeln!(
out,
"{}STORED {} cas={}…",
indent,
keyw,
&cas[..cas.len().min(16)]
)?;
}
etree::TextNode::Plain(_) | etree::TextNode::Data(_) => {}
etree::TextNode::Chain { extfields } => {
let signer = extfields.get("signer").map(|s| s.as_str()).unwrap_or("?");
let payload = extfields.get("payload").map(|s| s.as_str()).unwrap_or("?");
let short_payload = &payload[..payload.len().min(16)];
writeln!(
out,
"{}CHAIN signer={} payload={}…",
indent, signer, short_payload
)?;
}
etree::TextNode::Include { hash } => {
writeln!(out, "{}INCLUDE {}…", indent, &hash[..hash.len().min(16)])?;
}
etree::TextNode::Conflict { keyw, .. } => {
writeln!(out, "{}CONFLICT {}", indent, keyw)?;
}
}
}
Ok(())
}
fn list_tree_to_nodes(tree: &etree::TextTree, depth: usize, out: &mut Vec<output::ListNode>) {
for node in tree {
match node {
etree::TextNode::BeginEnd { keyw, txt } => {
let mut children = Vec::new();
list_tree_to_nodes(txt, depth + 1, &mut children);
out.push(output::ListNode {
kind: "begin-end",
word: keyw.clone(),
depth,
cipher: None,
pbkdf: None,
cas: None,
signer: None,
payload: None,
children,
});
}
etree::TextNode::Encrypted {
keyw, extfields, ..
} => {
let cipher = extfields
.get("cipher")
.cloned()
.or_else(|| Some("aes-256-siv".to_string()));
let pbkdf = extfields
.get("pbkdf")
.and_then(|s| s.split('$').nth(1).map(String::from))
.or_else(|| Some("legacy".to_string()));
out.push(output::ListNode {
kind: "encrypted",
word: keyw.clone(),
depth,
cipher,
pbkdf,
cas: None,
signer: None,
payload: None,
children: Vec::new(),
});
}
etree::TextNode::Stored { keyw, cas } => {
out.push(output::ListNode {
kind: "stored",
word: keyw.clone(),
depth,
cipher: None,
pbkdf: None,
cas: Some(cas.clone()),
signer: None,
payload: None,
children: Vec::new(),
});
}
etree::TextNode::Plain(_) | etree::TextNode::Data(_) => {}
etree::TextNode::Chain { extfields } => {
out.push(output::ListNode {
kind: "chain",
word: String::new(),
depth,
cipher: None,
pbkdf: None,
cas: None,
signer: extfields.get("signer").cloned(),
payload: extfields.get("payload").cloned(),
children: Vec::new(),
});
}
etree::TextNode::Include { hash } => {
out.push(output::ListNode {
kind: "include",
word: hash.clone(),
depth,
cipher: None,
pbkdf: None,
cas: None,
signer: None,
payload: None,
children: Vec::new(),
});
}
etree::TextNode::Conflict { keyw, .. } => {
out.push(output::ListNode {
kind: "conflict",
word: keyw.clone(),
depth,
cipher: None,
pbkdf: None,
cas: None,
signer: None,
payload: None,
children: Vec::new(),
});
}
}
}
}
fn verify_files(common: CommonArgs, output: OutputArgs) -> Result<()> {
let policy = resolve_policy(&common)?;
let mut paops = ParseOps::new(policy)?;
apply_common(&common, &mut paops);
let files = pair_inputs_to_outputs(
&output.files,
&output.output,
&output.prefix,
output.output_dir.as_deref(),
);
let mut issues = 0usize;
for (path_in, _) in &files {
if paops.io.verbose {
eprintln!("Verifying {}", path_in);
}
let reader: Box<dyn BufRead> = if path_in == "-" {
Box::new(BufReader::new(std::io::stdin()))
} else {
Box::new(BufReader::new(File::open(path_in).map_err(|e| {
Error::Msg(format!("Failed to open {}: {}", path_in, e))
})?))
};
paops.runtime.fname = path_in.clone();
let tree = match etree::parse(reader, &mut paops) {
Ok(t) => t,
Err(e) => {
eprintln!("FAIL {}: parse error: {}", path_in, e);
issues += 1;
continue;
}
};
for node in &tree {
let node_issues = verify_node(node, &mut paops);
issues += node_issues;
}
if issues == 0 {
eprintln!("OK {}", path_in);
}
}
if issues > 0 {
return Err(Error::Msg(format!("{} issue(s) found", issues)));
}
Ok(())
}
fn verify_node(node: &etree::TextNode, paops: &mut ParseOps) -> usize {
match node {
etree::TextNode::Stored { keyw, cas } => match cas::load(cas, paops) {
Ok(_) => 0,
Err(e) => {
eprintln!("FAIL: CAS pointer '{}' for WORD '{}': {}", cas, keyw, e);
1
}
},
etree::TextNode::Encrypted { txt, extfields, .. } => {
let mut n = 0;
for child in txt {
n += verify_node(child, paops);
}
if let Some(cipher_str) = extfields.get("cipher") {
if let Err(e) = cipher::parse_cipher_extfield(cipher_str) {
eprintln!("FAIL: cipher extfield '{}': {}", cipher_str, e);
n += 1;
}
}
if let Some(phc_str) = extfields.get("pbkdf") {
if let Err(e) = pbkdf::parse_phc(phc_str) {
eprintln!("FAIL: pbkdf extfield '{}': {}", phc_str, e);
n += 1;
}
}
n
}
etree::TextNode::BeginEnd { txt, .. } => {
let mut n = 0;
for child in txt {
n += verify_node(child, paops);
}
n
}
_ => 0,
}
}
fn resolve_policy(common: &CommonArgs) -> Result<Box<dyn crypto::CryptoPolicy>> {
let explicit_policy = common.policy.clone();
let mut policy_name = explicit_policy
.clone()
.unwrap_or_else(|| consts::DEFAULT_POLICY.to_string());
let fips = common.fips
|| (cfg!(unix)
&& match fs::read_to_string("/proc/sys/crypto/fips_enabled") {
Ok(s) => s.starts_with('1'),
Err(_) => false,
});
if fips {
if let Some(p) = explicit_policy.as_deref() {
if p != "nist" {
return Err(Error::Msg(format!(
"Policy setting of '{}' conflicts with --fips",
p
)));
}
}
policy_name = "nist".to_string();
}
Ok(make_policy(&policy_name))
}
fn resolve_separators(common: &CommonArgs) -> (String, String) {
if let Some(ref lang) = common.lang {
if let Some((left, right)) = consts::lang_separators(lang) {
let l = if common.left_separator == consts::DEFAULT_LEFT_SEP {
left.to_string()
} else {
common.left_separator.clone()
};
let r = if common.right_separator == consts::DEFAULT_RIGHT_SEP {
right.to_string()
} else {
common.right_separator.clone()
};
return (l, r);
}
}
(
common.left_separator.clone(),
common.right_separator.clone(),
)
}
fn apply_common(common: &CommonArgs, paops: &mut ParseOps) {
if let Some(dir) = common.casdir.clone() {
paops.io.casdir = dir;
} else if Path::new("cas").is_dir() {
paops.io.casdir = Path::new("cas").to_path_buf();
} else {
paops.io.casdir = Path::new(".").to_path_buf();
}
paops.io.verbose = common.verbose && !common.quiet;
paops.io.inline_data = common.inline || common.casdir.is_none();
paops.max_depth = common.max_depth;
let (left, right) = resolve_separators(common);
paops.separators.left = left;
paops.separators.right = right;
paops.passwords.extend(common.password.clone());
}
fn pki_keygen(_common: CommonArgs, a: KeygenSubcmd) -> Result<()> {
let kind: pki::SigAlgKind = a.alg.parse()?;
let mut rng = botan::RandomNumberGenerator::new_system().map_err(Error::botan)?;
let (priv_pem, pub_pem) = pki::keygen(kind, &mut rng)?;
write_key_or_stdout(a.out_priv.as_deref(), priv_pem.as_bytes())?;
write_key_or_stdout(a.out_pub.as_deref(), pub_pem.as_bytes())?;
Ok(())
}
fn pki_sign(_common: CommonArgs, a: SignSubcmd) -> Result<()> {
if a.key.is_empty() {
return Err(Error::Msg(
"sign: at least one --key-file is required".into(),
));
}
let kind: pki::SigAlgKind = a.alg.parse()?;
let msg = read_file_or_stdin(a.input.as_deref())?;
let mut rng = botan::RandomNumberGenerator::new_system().map_err(Error::botan)?;
let out_path = match (&a.out, &a.input) {
(Some(p), _) => p.clone(),
(None, Some(input)) => append_sig_ext(input),
(None, None) => PathBuf::from("-"),
};
if a.key.len() == 1 {
let priv_pem = fs::read_to_string(&a.key[0])?;
let sig = pki::sign(kind, &priv_pem, &msg, &mut rng)?;
if out_path == Path::new("-") {
std::io::stdout().write_all(&sig)?;
} else {
fs::write(&out_path, &sig)?;
}
return Ok(());
}
let mut entries = Vec::with_capacity(a.key.len());
for key_path in &a.key {
let priv_pem = fs::read_to_string(key_path)?;
let botan_priv = botan::Privkey::load_pem(&priv_pem).map_err(Error::botan)?;
let botan_pub = botan_priv.pubkey().map_err(Error::botan)?;
let pub_pem = botan_pub.pem_encode().map_err(Error::botan)?;
let fp = capability::KeyFp::from_pem(&pub_pem)?;
let sig = pki::sign(kind, &priv_pem, &msg, &mut rng)?;
entries.push(pki::SigEntry {
alg: kind,
fp: fp.to_hex(),
sig,
});
}
let bundle = pki::SigBundle { entries };
let body = bundle.serialize();
if out_path == Path::new("-") {
std::io::stdout().write_all(body.as_bytes())?;
} else {
fs::write(&out_path, body.as_bytes())?;
}
Ok(())
}
fn pki_verify_sig(_common: CommonArgs, a: VerifySigSubcmd) -> Result<()> {
if a.key.is_empty() {
return Err(Error::Msg(
"verify-sig: at least one --key-file is required".into(),
));
}
let kind: pki::SigAlgKind = a.alg.parse()?;
let sig_bytes = match (&a.sig, &a.input) {
(Some(p), _) => fs::read(p)?,
(None, Some(input)) => fs::read(append_sig_ext(input))?,
(None, None) => {
return Err(Error::Msg(
"verify-sig: no signature file or input file given".into(),
));
}
};
let msg = read_file_or_stdin(a.input.as_deref())?;
if a.key.len() == 1 {
let pub_pem = fs::read_to_string(&a.key[0])?;
let ok = pki::verify(kind, &pub_pem, &msg, &sig_bytes)?;
return if ok {
Ok(())
} else {
Err(Error::Msg("signature verification failed".into()))
};
}
let body = String::from_utf8(sig_bytes)
.map_err(|e| Error::Msg(format!("signature bundle is not UTF-8: {e}")))?;
let bundle = pki::SigBundle::parse(&body)?;
if bundle.entries.len() != a.key.len() {
return Err(Error::Msg(format!(
"verify-sig: {} signatures in bundle but {} pubkeys supplied",
bundle.entries.len(),
a.key.len()
)));
}
let mut by_fp: HashMap<String, String> = HashMap::new();
for key_path in &a.key {
let pem = fs::read_to_string(key_path)?;
let fp = capability::KeyFp::from_pem(&pem)?;
by_fp.insert(fp.to_hex(), pem);
}
for entry in &bundle.entries {
let pem = by_fp
.get(&entry.fp)
.ok_or_else(|| Error::Msg(format!("no pubkey for fp {}", entry.fp)))?;
let ok = pki::verify(entry.alg, pem, &msg, &entry.sig)?;
if !ok {
return Err(Error::Msg(format!(
"signature verification failed for fp {}",
entry.fp
)));
}
}
Ok(())
}
fn append_sig_ext(input: &Path) -> PathBuf {
let mut p = input.to_path_buf();
if p.extension().and_then(|e| e.to_str()) == Some("sig") {
return p;
}
match p.extension() {
Some(e) => {
let mut new_ext = e.to_os_string();
new_ext.push(".sig");
p.set_extension(new_ext);
}
None => {
p.set_extension("sig");
}
}
p
}
fn pki_fingerprint(a: FingerprintSubcmd) -> Result<()> {
let pem = fs::read_to_string(&a.key)?;
let fp = capability::KeyFp::from_pem(&pem)?;
println!("{}", fp);
Ok(())
}
fn compute_chain_head(path: &str) -> Result<String> {
let mut paops = ParseOps::new(Box::new(crate::crypto::CryptoPolicyDefault {}))?;
paops.runtime.fname = path.to_string();
let reader: Box<dyn BufRead> = if path == "-" {
Box::new(BufReader::new(std::io::stdin()))
} else {
Box::new(BufReader::new(File::open(path)?))
};
let tree = etree::parse(reader, &mut paops)?;
let mut blob = Vec::new();
etree::tree_write(&mut blob, &tree, &mut paops)?;
let policy = crate::crypto::CryptoPolicyDefault {};
crate::crypto::hexdigest("sha3-256", &blob, &policy)
}
fn snapshot_file(a: SnapshotSubcmd) -> Result<()> {
let head = compute_chain_head(&a.file)?;
println!("{}", head);
Ok(())
}
fn pin_file(a: PinSubcmd) -> Result<()> {
let head = compute_chain_head(&a.file)?;
if head == a.expected {
println!("OK");
Ok(())
} else {
Err(Error::msg(format!(
"chain head mismatch: expected {}, got {}",
a.expected, head
)))
}
}
fn audit_log_stream(_common: CommonArgs, a: AuditLogSubcmd) -> Result<()> {
let priv_pem = fs::read_to_string(&a.signer)?;
let mut tree: etree::TextTree = if Path::new(&a.file).exists() {
let mut paops = ParseOps::new(Box::new(crate::crypto::CryptoPolicyDefault {}))?;
paops.runtime.fname = a.file.clone();
let f = File::open(&a.file)?;
etree::parse(BufReader::new(f), &mut paops)?
} else {
Vec::new()
};
let mut last_anchor = latest_anchor_hash(&tree);
let stdin = std::io::stdin();
let mut line_count = 0usize;
for line_in in stdin.lock().lines() {
let line = line_in?;
let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
tree.push(etree::TextNode::Plain(trimmed.to_string()));
let chain_node =
build_chain_anchor_node_with_parent(&tree, &priv_pem, "append", "", last_anchor)?;
if let etree::TextNode::Chain { extfields } = &chain_node {
if let Ok(signed) = ledger::SignedAnchor::from_extfields(extfields) {
if let Ok(h) = signed.id() {
last_anchor = Some(h);
}
}
}
tree.push(chain_node);
line_count += 1;
}
if line_count == 0 {
eprintln!("audit-log: no lines read from stdin; file unchanged.");
return Ok(());
}
let tmp_path = format!("{}.tmp", a.file);
let mut paops = ParseOps::new(Box::new(crate::crypto::CryptoPolicyDefault {}))?;
paops.runtime.fname = a.file.clone();
let mut writer = BufWriter::new(File::create(&tmp_path)?);
etree::tree_write(&mut writer, &tree, &mut paops)?;
writer.flush()?;
drop(writer);
fs::rename(&tmp_path, &a.file)?;
eprintln!("audit-log: appended {} anchor(s) to {}", line_count, a.file);
Ok(())
}
fn latest_anchor_hash(tree: &etree::TextTree) -> Option<ledger::AnchorHash> {
let mut all = Vec::new();
let _ = walk_for_chains(tree, &mut all);
all.pop()
}
fn build_chain_anchor_node_with_parent(
tree: &etree::TextTree,
priv_pem: &str,
operation: &str,
words_csv: &str,
parent: Option<ledger::AnchorHash>,
) -> Result<etree::TextNode> {
use crate::ledger::{Anchor, PayloadHash, SignerId};
use crate::pki::SigAlgKind;
use std::collections::BTreeMap;
let botan_priv = botan::Privkey::load_pem(priv_pem).map_err(Error::botan)?;
let botan_pub = botan_priv.pubkey().map_err(Error::botan)?;
let pub_pem = botan_pub.pem_encode().map_err(Error::botan)?;
let fp = capability::KeyFp::from_pem(&pub_pem)?;
let mut paops = ParseOps::new(Box::new(crate::crypto::CryptoPolicyDefault {}))?;
let blob = etree::tree_to_blob(tree, &mut paops)?;
let policy = crate::crypto::CryptoPolicyDefault {};
let payload_hex = crate::crypto::hexdigest("sha3-256", &blob, &policy)?;
let mut payload_arr = [0u8; 32];
payload_arr.copy_from_slice(&hex::decode(payload_hex)?);
let payload_hash = PayloadHash(payload_arr);
let mutations = if words_csv.is_empty() {
operation.to_string()
} else {
format!("{}+{}", operation, words_csv)
};
let parents: Vec<_> = parent.into_iter().collect();
let signer = SignerId::new(SigAlgKind::Ed25519, fp);
let anchor = Anchor::builder(signer, payload_hash)
.with_parents(parents)
.with_mutations(mutations)
.build();
let signed = anchor.sign(priv_pem, &pub_pem, SigAlgKind::Ed25519)?;
let extfields: BTreeMap<String, String> = signed.to_extfields();
Ok(etree::TextNode::Chain { extfields })
}
fn verify_chain_files(common: CommonArgs, a: VerifyChainSubcmd) -> Result<()> {
let mut trust: HashMap<String, String> = HashMap::new();
for pem_path in &a.trust_roots {
let pem = fs::read_to_string(pem_path)?;
let fp = capability::KeyFp::from_pem(&pem)?;
trust.insert(fp.to_hex(), pem);
}
let cap_policy = common
.policy_file
.as_ref()
.map(|p| cappolicy::CapPolicy::load_file(p))
.transpose()?;
let policy = resolve_policy(&common)?;
let mut paops = ParseOps::new(policy)?;
apply_common(&common, &mut paops);
let mut json_reports: Vec<output::VerifyChainFileReport> = Vec::new();
let mut any_failure = false;
for path_in in &a.files {
let result = verify_chain_one_file(path_in, &mut paops, &trust, cap_policy.as_ref());
match common.format {
output::OutputFormat::Text => match &result {
Ok(()) => println!("OK {}", path_in),
Err(e) => {
any_failure = true;
eprintln!("FAIL {}: {}", path_in, e);
}
},
output::OutputFormat::Json => {
let report = verify_chain_report_from_result(path_in, &result, &mut paops);
if !report.ok {
any_failure = true;
}
json_reports.push(report);
}
}
}
if matches!(common.format, output::OutputFormat::Json) {
let payload = output::VerifyChainOutput {
ok: !any_failure,
files: json_reports,
};
println!("{}", output::to_json(&payload)?);
}
if any_failure {
Err(Error::msg("one or more files failed chain verification"))
} else {
Ok(())
}
}
fn verify_chain_report_from_result(
path_in: &str,
result: &Result<()>,
paops: &mut ParseOps,
) -> output::VerifyChainFileReport {
let (ok, message) = match result {
Ok(()) => (true, None),
Err(e) => (false, Some(e.to_string())),
};
let reader: Box<dyn BufRead> = if path_in == "-" {
Box::new(BufReader::new(std::io::stdin()))
} else {
match File::open(path_in) {
Ok(f) => Box::new(BufReader::new(f)),
Err(_) => {
return output::VerifyChainFileReport {
path: path_in.to_string(),
ok: false,
anchors_total: 0,
signers: Vec::new(),
forks: Vec::new(),
errors: vec![output::VerifyError {
anchor: None,
message: message.unwrap_or_else(|| "unknown error".into()),
}],
};
}
}
};
paops.runtime.fname = path_in.into();
let tree = match etree::parse(reader, paops) {
Ok(t) => t,
Err(e) => {
return output::VerifyChainFileReport {
path: path_in.to_string(),
ok: false,
anchors_total: 0,
signers: Vec::new(),
forks: Vec::new(),
errors: vec![output::VerifyError {
anchor: None,
message: e.to_string(),
}],
};
}
};
let mut dag = ledger::AnchorDag::new();
if let Err(e) = collect_chain_anchors(&tree, &mut dag) {
return output::VerifyChainFileReport {
path: path_in.to_string(),
ok: false,
anchors_total: 0,
signers: Vec::new(),
forks: Vec::new(),
errors: vec![output::VerifyError {
anchor: None,
message: e.to_string(),
}],
};
}
let tips = dag.tips();
let signers: Vec<String> = dag
.iter()
.map(|(_, s)| s.anchor.signer.to_string())
.collect();
let forks: Vec<output::ForkPoint> = tips
.into_iter()
.map(|id| {
let parents = dag
.get(&id)
.map(|s| s.anchor.parents.iter().map(|p| p.to_string()).collect())
.unwrap_or_default();
output::ForkPoint {
anchor: id.to_string(),
parents,
}
})
.collect();
let errors = match message {
Some(m) => vec![output::VerifyError {
anchor: None,
message: m,
}],
None => Vec::new(),
};
output::VerifyChainFileReport {
path: path_in.to_string(),
ok,
anchors_total: dag.len(),
signers,
forks,
errors,
}
}
fn verify_chain_one_file(
path_in: &str,
paops: &mut ParseOps,
trust: &HashMap<String, String>,
cap_policy: Option<&cappolicy::CapPolicy>,
) -> Result<()> {
paops.runtime.fname = path_in.into();
let reader: Box<dyn BufRead> = if path_in == "-" {
Box::new(BufReader::new(std::io::stdin()))
} else {
Box::new(BufReader::new(File::open(path_in).map_err(|e| {
Error::Msg(format!("Failed to open {}: {}", path_in, e))
})?))
};
let tree = etree::parse(reader, paops)?;
let mut dag = ledger::AnchorDag::new();
collect_chain_anchors(&tree, &mut dag)?;
let report = dag.verify_signatures(|fp_hex| trust.get(fp_hex).cloned());
let mut errors: Vec<String> = Vec::new();
for r in &report.reports {
if !r.ok {
if let Some(ref e) = r.error {
errors.push(format!("{}: {}", r.id, e));
}
}
if let Some(p) = cap_policy {
if let Some(signed) = dag.get(&r.id) {
if !p.trust_root_allows(&signed.anchor.signer) {
errors.push(format!(
"{}: signer {} not in policy trust_roots",
r.id, signed.anchor.signer
));
}
}
}
}
if let Some(p) = cap_policy {
if p.chain.require_monotonic_timestamps {
errors.extend(check_monotonic_timestamps(&dag));
}
}
let payload_errors = verify_payload_hashes(&tree, paops)?;
errors.extend(payload_errors);
if !errors.is_empty() {
return Err(Error::msg(format!(
"{} anchor(s) failed verification: {}",
errors.len(),
errors.join("; ")
)));
}
Ok(())
}
fn verify_payload_hashes(tree: &etree::TextTree, paops: &mut ParseOps) -> Result<Vec<String>> {
let policy = crate::crypto::CryptoPolicyDefault {};
let mut errors = Vec::new();
let mut prefix: etree::TextTree = Vec::new();
for node in tree {
if let etree::TextNode::Chain { extfields } = node {
let blob = etree::tree_to_blob(&prefix, paops)?;
let recomputed = crate::crypto::hexdigest("sha3-256", &blob, &policy)?;
let recorded = extfields.get("payload").map(|s| s.as_str()).unwrap_or("");
if recomputed != recorded {
errors.push(format!(
"payload mismatch: anchor payload={} but file content hashes to {}",
recorded, recomputed
));
}
}
prefix.push(node.clone());
}
Ok(errors)
}
fn check_monotonic_timestamps(dag: &ledger::AnchorDag) -> Vec<String> {
let mut errors = Vec::new();
for (id, signed) in dag.iter() {
let Some(child_ts) = signed.anchor.timestamp.as_ref() else {
continue;
};
for parent_id in &signed.anchor.parents {
let Some(parent) = dag.get(parent_id) else {
continue;
};
let Some(parent_ts) = parent.anchor.timestamp.as_ref() else {
continue;
};
if child_ts < parent_ts {
errors.push(format!(
"{}: timestamp {} older than parent {} ({})",
id, child_ts, parent_id, parent_ts
));
}
}
}
errors
}
fn collect_chain_anchors(tree: &etree::TextTree, dag: &mut ledger::AnchorDag) -> Result<()> {
for node in tree {
match node {
etree::TextNode::Chain { extfields } => {
let signed = ledger::SignedAnchor::from_extfields(extfields)?;
dag.push(signed)
.map_err(|e| Error::msg(format!("DAG construction failed: {}", e)))?;
}
etree::TextNode::BeginEnd { txt, .. } | etree::TextNode::Encrypted { txt, .. } => {
collect_chain_anchors(txt, dag)?;
}
_ => {}
}
}
Ok(())
}
fn read_file_or_stdin(path: Option<&Path>) -> Result<Vec<u8>> {
match path {
Some(p) if p != Path::new("-") => Ok(fs::read(p)?),
_ => {
use std::io::Read;
let mut buf = Vec::new();
std::io::stdin().read_to_end(&mut buf)?;
Ok(buf)
}
}
}
fn write_key_or_stdout(path: Option<&Path>, data: &[u8]) -> Result<()> {
match path {
Some(p) if p != Path::new("-") => {
fs::write(p, data)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(p, fs::Permissions::from_mode(0o600))?;
}
}
_ => {
std::io::stdout().write_all(data)?;
}
}
Ok(())
}