From c5af2fadec2308628fbeae9bf4ca58698702a8ff Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 17:02:54 +0000 Subject: [PATCH] Add PCF-SIG signing/verification to CLI tooling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a generic `pcf-sig` tool (crate tools/pcf-sig, lib pcf_sig_cli + bin pcf-sig) for signing and verifying any PCF file with PCF-SIG (Ed25519): keygen, incremental sign, verify with optional trust check, and key listing. Signing is incremental by default — partitions already covered by a valid signature from the same key are skipped — with --resign to force. Wire PCF-SIG into the `pfs` CLI: keygen and verify-sig delegate to pcf_sig_cli, and every mutating command (mkfs/mkdir/put/mv/rm/create/update) accepts --key to auto-sign after its commit. Because PFS-MS is append-only with a backward-linked session chain, naively appending signature partitions corrupts the chain. Signing a PFS-MS file is therefore committed as a dedicated signature session (pfs-ms sign_archive), covering content and node records; the generic pcf-sig sign refuses PFS-MS files and points to `pfs sign`. Includes roundtrip/incremental/tamper/refusal tests for both crates, a dedicated CI workflow, release-binary packaging for pcf-sig, and README docs. --- .github/workflows/build-binaries.yml | 10 +- .github/workflows/ci-pcf-sig.yml | 55 +++ Cargo.toml | 1 + reference/PCF-SIG-v1.0/README.md | 16 + reference/PFS-MS-v1.0/Cargo.toml | 10 + reference/PFS-MS-v1.0/README.md | 31 ++ reference/PFS-MS-v1.0/src/bin/pfs.rs | 171 ++++++-- reference/PFS-MS-v1.0/src/error.rs | 10 + reference/PFS-MS-v1.0/src/lib.rs | 2 + reference/PFS-MS-v1.0/src/sign.rs | 220 +++++++++++ reference/PFS-MS-v1.0/tests/sign.rs | 141 +++++++ tools/pcf-sig/Cargo.toml | 40 ++ tools/pcf-sig/README.md | 87 +++++ tools/pcf-sig/src/lib.rs | 557 +++++++++++++++++++++++++++ tools/pcf-sig/src/main.rs | 196 ++++++++++ tools/pcf-sig/tests/cli_roundtrip.rs | 324 ++++++++++++++++ 16 files changed, 1831 insertions(+), 40 deletions(-) create mode 100644 .github/workflows/ci-pcf-sig.yml create mode 100644 reference/PFS-MS-v1.0/src/sign.rs create mode 100644 reference/PFS-MS-v1.0/tests/sign.rs create mode 100644 tools/pcf-sig/Cargo.toml create mode 100644 tools/pcf-sig/README.md create mode 100644 tools/pcf-sig/src/lib.rs create mode 100644 tools/pcf-sig/src/main.rs create mode 100644 tools/pcf-sig/tests/cli_roundtrip.rs diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index b6bd93e..9981ac4 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -88,6 +88,7 @@ jobs: run: | cargo build --release --target ${{ matrix.target }} -p pcf-debug cargo build --release --target ${{ matrix.target }} -p pcf-compact + cargo build --release --target ${{ matrix.target }} -p pcf-sig-cli --bin pcf-sig cargo build --release --target ${{ matrix.target }} -p pfs-ms --bin pfs - name: Build (macOS universal2) @@ -97,6 +98,7 @@ jobs: for t in ${{ matrix.macos-targets }}; do cargo build --release --target "$t" -p pcf-debug cargo build --release --target "$t" -p pcf-compact + cargo build --release --target "$t" -p pcf-sig-cli --bin pcf-sig cargo build --release --target "$t" -p pfs-ms --bin pfs done mkdir -p target/universal2-apple-darwin/release @@ -106,11 +108,15 @@ jobs: lipo -create -output target/universal2-apple-darwin/release/pcf-compact \ target/x86_64-apple-darwin/release/pcf-compact \ target/aarch64-apple-darwin/release/pcf-compact + lipo -create -output target/universal2-apple-darwin/release/pcf-sig \ + target/x86_64-apple-darwin/release/pcf-sig \ + target/aarch64-apple-darwin/release/pcf-sig lipo -create -output target/universal2-apple-darwin/release/pfs \ target/x86_64-apple-darwin/release/pfs \ target/aarch64-apple-darwin/release/pfs file target/universal2-apple-darwin/release/pcf-debug file target/universal2-apple-darwin/release/pcf-compact + file target/universal2-apple-darwin/release/pcf-sig file target/universal2-apple-darwin/release/pfs - name: Stage and archive (unix) @@ -120,7 +126,7 @@ jobs: VERSION='${{ needs.resolve-version.outputs.version }}' TARGET='${{ matrix.target }}' mkdir -p staging - for bin in pcf-debug pcf-compact pfs; do + for bin in pcf-debug pcf-compact pcf-sig pfs; do STAGE="staging/${bin}-${VERSION}-${TARGET}" mkdir -p "$STAGE" cp "target/${TARGET}/release/${bin}" "$STAGE/${bin}" @@ -136,7 +142,7 @@ jobs: $version = '${{ needs.resolve-version.outputs.version }}' $target = '${{ matrix.target }}' New-Item -ItemType Directory -Force staging | Out-Null - foreach ($bin in @('pcf-debug', 'pcf-compact', 'pfs')) { + foreach ($bin in @('pcf-debug', 'pcf-compact', 'pcf-sig', 'pfs')) { $stage = "staging/$bin-$version-$target" New-Item -ItemType Directory -Force $stage | Out-Null Copy-Item "target/$target/release/$bin.exe" "$stage/$bin.exe" diff --git a/.github/workflows/ci-pcf-sig.yml b/.github/workflows/ci-pcf-sig.yml new file mode 100644 index 0000000..06dadf6 --- /dev/null +++ b/.github/workflows/ci-pcf-sig.yml @@ -0,0 +1,55 @@ +name: CI / Rust (pcf-sig CLI) + +on: + push: + branches: [master] + pull_request: + branches: [master] + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: "-D warnings" + +defaults: + run: + working-directory: tools/pcf-sig + +jobs: + fmt: + name: rustfmt + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - run: cargo fmt --all -- --check + + clippy: + name: clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: tools/pcf-sig + - run: cargo clippy --all-targets --all-features -- -D warnings + + test: + name: test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: tools/pcf-sig + - run: cargo build --verbose + - run: cargo test --all-targets --verbose diff --git a/Cargo.toml b/Cargo.toml index 8f2f4c7..f5bf7ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,4 +6,5 @@ members = [ "reference/PCF-SIG-v1.0", "tools/pcf-debug", "tools/pcf-compact", + "tools/pcf-sig", ] diff --git a/reference/PCF-SIG-v1.0/README.md b/reference/PCF-SIG-v1.0/README.md index 1fd3ce7..1a1f36d 100644 --- a/reference/PCF-SIG-v1.0/README.md +++ b/reference/PCF-SIG-v1.0/README.md @@ -75,6 +75,22 @@ for report in verify_all_with_recheck(&mut c)? { # Ok::<(), pcf_sig::Error>(()) ``` +## Command-line tool + +A ready-made CLI lives in [`tools/pcf-sig`](../../tools/pcf-sig) (`pcf-sig`), +built on this crate: + +```sh +pcf-sig keygen id.key id.pub # 32-byte raw Ed25519 seed + public key +pcf-sig sign file.pcf --key id.key # incremental by default; --resign to redo +pcf-sig verify file.pcf --key id.pub # per-signature / per-partition report +pcf-sig keys file.pcf # list embedded PCFSIG_KEY fingerprints +``` + +A PFS-MS archive is a PCF file, so `pcf-sig verify`/`keys` work on it directly; +to *sign* a PFS-MS file use `pfs sign`, which commits the signature as a PFS +session (see [`reference/PFS-MS-v1.0`](../PFS-MS-v1.0)). + ## Trust patterns The profile describes one non-X.509 way for an application to express trust diff --git a/reference/PFS-MS-v1.0/Cargo.toml b/reference/PFS-MS-v1.0/Cargo.toml index 85c302c..664d496 100644 --- a/reference/PFS-MS-v1.0/Cargo.toml +++ b/reference/PFS-MS-v1.0/Cargo.toml @@ -38,3 +38,13 @@ flate2 = { version = "1", default-features = false, features = ["rust_backend"] # Portable file modification-time setting for the directory-import/extract tools. filetime = "0.2" + +# Shared PCF-SIG command-line logic, so `pfs` can run keygen / verify-sig using +# exactly the same implementation as the standalone `pcf-sig` tool. +pcf-sig-cli = { path = "../../tools/pcf-sig", version = "0.0.8" } + +# The PCF-SIG signing primitives. Signing a PFS-MS file cannot simply append +# partitions (that would break the backward-linked session chain); instead the +# PCFSIG_KEY / PCFSIG_SIG partitions are committed as a dedicated PFS session +# (see `src/sign.rs`), built from these primitives. +pcf-sig = { path = "../PCF-SIG-v1.0", version = "0.0.8" } diff --git a/reference/PFS-MS-v1.0/README.md b/reference/PFS-MS-v1.0/README.md index 990377a..9350ebd 100644 --- a/reference/PFS-MS-v1.0/README.md +++ b/reference/PFS-MS-v1.0/README.md @@ -136,6 +136,37 @@ on extract; pass `--no-metadata` (on either side) to skip this, and `--store` to disable compression. Symlinks and other non-regular files are skipped with a warning. +### Signing (PCF-SIG) + +`pfs` can sign archives with [PCF-SIG](../PCF-SIG-v1.0) (Ed25519). Because +PFS-MS is append-only with a backward-linked session chain, a signature is +**committed as its own PFS session** carrying the `PCFSIG_KEY` / `PCFSIG_SIG` +partitions — not appended out of band — so `verify` keeps working. + +``` +# Generate a keypair (delegates to the pcf-sig tool). +cargo run --bin pfs -- keygen id.key id.pub + +# Sign content + node records not yet signed by this key (incremental). +cargo run --bin pfs -- sign fs.pfs --key id.key # no-op if nothing new +cargo run --bin pfs -- sign fs.pfs --key id.key --resign # re-sign everything + +# Verify embedded signatures (optionally assert a trusted public key). +cargo run --bin pfs -- verify-sig fs.pfs --key id.pub +``` + +Every mutating command (`mkfs`, `mkdir`, `put`, `mv`, `rm`, `create`, `update`) +also accepts `--key ` to **auto-sign** right after its commit, so each +operation adds one signature covering just the partitions it introduced: + +``` +cargo run --bin pfs -- mkfs fs.pfs --key id.key +echo hi | cargo run --bin pfs -- put fs.pfs hello.txt - --key id.key +``` + +Signatures cover file content and node records; PFS-MS's own inter-session hash +chain (checked by `verify`) already makes session records tamper-evident. + ## Layout ``` diff --git a/reference/PFS-MS-v1.0/src/bin/pfs.rs b/reference/PFS-MS-v1.0/src/bin/pfs.rs index a4e82e9..740519d 100644 --- a/reference/PFS-MS-v1.0/src/bin/pfs.rs +++ b/reference/PFS-MS-v1.0/src/bin/pfs.rs @@ -17,7 +17,15 @@ //! pfs create [--store] [--no-metadata] //! pfs update [--delete] [--store] [--no-metadata] //! pfs extract [--at ] [--at-time ] [--no-metadata] +//! pfs keygen +//! pfs sign --key [--resign] +//! pfs verify-sig [--key ] [--no-recheck] //! ``` +//! +//! Every mutating subcommand also accepts `--key ` to auto-sign the file +//! after its session is committed. Signing is incremental: only the partitions +//! added by that operation are covered, so the file accumulates one PCF-SIG +//! signature per session. use std::collections::{HashMap, HashSet}; use std::fs::{File, OpenOptions}; @@ -62,6 +70,9 @@ fn run(args: &[String]) -> CliResult { "create" => cmd_create(rest), "update" => cmd_update(rest), "extract" => cmd_extract(rest), + "keygen" => cmd_keygen(rest), + "sign" => cmd_sign(rest), + "verify-sig" => cmd_verify_sig(rest), "" | "help" | "-h" | "--help" => { print_usage(); Ok(()) @@ -72,7 +83,7 @@ fn run(args: &[String]) -> CliResult { fn print_usage() { eprintln!( - "usage:\n pfs mkfs \n pfs mkdir \n pfs put [] [--store]\n pfs mv \n pfs rm \n pfs ls []\n pfs cat \n pfs get \n pfs log \n pfs verify \n pfs create [--store] [--no-metadata]\n pfs update [--delete] [--store] [--no-metadata]\n pfs extract [--at ] [--at-time ] [--no-metadata]" + "usage:\n pfs mkfs [--key ]\n pfs mkdir [--key ]\n pfs put [] [--store] [--key ]\n pfs mv [--key ]\n pfs rm [--key ]\n pfs ls []\n pfs cat \n pfs get \n pfs log \n pfs verify \n pfs create [--store] [--no-metadata] [--key ]\n pfs update [--delete] [--store] [--no-metadata] [--key ]\n pfs extract [--at ] [--at-time ] [--no-metadata]\n pfs keygen \n pfs sign --key [--resign]\n pfs verify-sig [--key ] [--no-recheck]\n\nmutating commands accept --key to auto-sign after the commit." ); } @@ -82,6 +93,31 @@ fn arg<'a>(args: &'a [String], i: usize, what: &str) -> Result<&'a str, String> .ok_or_else(|| format!("missing argument: {what}")) } +/// Fetch the `i`-th positional from a [`Parsed`] command line. +fn pos<'a>(p: &'a Parsed, i: usize, what: &str) -> Result<&'a str, String> { + p.positional + .get(i) + .map(|s| s.as_str()) + .ok_or_else(|| format!("missing argument: {what}")) +} + +/// If a mutating command was given `--key `, sign the file after its +/// session has been committed. Signing commits a dedicated PFS signature +/// session (see [`pfs_ms::sign_archive`]); it is incremental, so each operation +/// adds one signature covering just the content/node partitions it introduced. +fn maybe_autosign(file: &str, key: Option<&String>) -> CliResult { + let Some(key) = key else { return Ok(()) }; + let outcome = + pfs_ms::sign_archive(Path::new(file), Path::new(key), false).map_err(|e| e.to_string())?; + if outcome.sig_partition_uid.is_some() { + eprintln!( + "pfs: auto-signed {} partition(s)", + outcome.signed_uids.len() + ); + } + Ok(()) +} + /// Parsed command line: positionals, boolean flags, and `--flag value` pairs. struct Parsed { positional: Vec, @@ -136,35 +172,28 @@ fn open_reader(path: &str) -> Result, String> { } fn cmd_mkfs(a: &[String]) -> CliResult { - let file = arg(a, 0, "")?; + let p = parse_flags(a, &["key"])?; + let file = pos(&p, 0, "")?; let f = File::create(file).map_err(|e| format!("cannot create '{file}': {e}"))?; FsWriter::mkfs(f, HashAlgo::Sha256).map_err(|e| e.to_string())?; - Ok(()) + maybe_autosign(file, p.values.get("key")) } fn cmd_mkdir(a: &[String]) -> CliResult { - let file = arg(a, 0, "")?; - let path = arg(a, 1, "")?; - open_writer(file)?.mkdir(path).map_err(|e| e.to_string()) + let p = parse_flags(a, &["key"])?; + let file = pos(&p, 0, "")?; + let path = pos(&p, 1, "")?; + open_writer(file)?.mkdir(path).map_err(|e| e.to_string())?; + maybe_autosign(file, p.values.get("key")) } fn cmd_put(a: &[String]) -> CliResult { - // `--store` (anywhere after the file) disables compression for this write. - let store = a.iter().any(|s| s == "--store"); - let positional: Vec<&str> = a - .iter() - .map(|s| s.as_str()) - .filter(|s| *s != "--store") - .collect(); - let file = positional - .first() - .copied() - .ok_or("missing argument: ")?; - let path = positional - .get(1) - .copied() - .ok_or("missing argument: ")?; - let src = positional.get(2).copied().unwrap_or("-"); + // `--store` disables compression for this write; `--key` auto-signs. + let p = parse_flags(a, &["key"])?; + let store = p.flags.contains("store"); + let file = pos(&p, 0, "")?; + let path = pos(&p, 1, "")?; + let src = p.positional.get(2).map(|s| s.as_str()).unwrap_or("-"); let data = if src == "-" { let mut buf = Vec::new(); std::io::stdin() @@ -176,20 +205,25 @@ fn cmd_put(a: &[String]) -> CliResult { }; let mut w = open_writer(file)?; w.set_compression(!store); - w.put_file(path, &data).map_err(|e| e.to_string()) + w.put_file(path, &data).map_err(|e| e.to_string())?; + maybe_autosign(file, p.values.get("key")) } fn cmd_mv(a: &[String]) -> CliResult { - let file = arg(a, 0, "")?; - let src = arg(a, 1, "")?; - let dst = arg(a, 2, "")?; - open_writer(file)?.mv(src, dst).map_err(|e| e.to_string()) + let p = parse_flags(a, &["key"])?; + let file = pos(&p, 0, "")?; + let src = pos(&p, 1, "")?; + let dst = pos(&p, 2, "")?; + open_writer(file)?.mv(src, dst).map_err(|e| e.to_string())?; + maybe_autosign(file, p.values.get("key")) } fn cmd_rm(a: &[String]) -> CliResult { - let file = arg(a, 0, "")?; - let path = arg(a, 1, "")?; - open_writer(file)?.rm(path).map_err(|e| e.to_string()) + let p = parse_flags(a, &["key"])?; + let file = pos(&p, 0, "")?; + let path = pos(&p, 1, "")?; + open_writer(file)?.rm(path).map_err(|e| e.to_string())?; + maybe_autosign(file, p.values.get("key")) } fn cmd_ls(a: &[String]) -> CliResult { @@ -262,27 +296,29 @@ fn cmd_verify(a: &[String]) -> CliResult { } fn cmd_create(a: &[String]) -> CliResult { - let p = parse_flags(a, &[])?; - let archive = p.positional.first().ok_or("missing argument: ")?; - let dir = p.positional.get(1).ok_or("missing argument: ")?; + let p = parse_flags(a, &["key"])?; + let archive = pos(&p, 0, "")?; + let dir = pos(&p, 1, "")?; let opts = SyncOptions { compress: !p.flags.contains("store"), metadata: !p.flags.contains("no-metadata"), delete: false, }; - pfs_ms::create_archive(Path::new(archive), Path::new(dir), &opts).map_err(|e| e.to_string()) + pfs_ms::create_archive(Path::new(archive), Path::new(dir), &opts).map_err(|e| e.to_string())?; + maybe_autosign(archive, p.values.get("key")) } fn cmd_update(a: &[String]) -> CliResult { - let p = parse_flags(a, &[])?; - let archive = p.positional.first().ok_or("missing argument: ")?; - let dir = p.positional.get(1).ok_or("missing argument: ")?; + let p = parse_flags(a, &["key"])?; + let archive = pos(&p, 0, "")?; + let dir = pos(&p, 1, "")?; let opts = SyncOptions { compress: !p.flags.contains("store"), metadata: !p.flags.contains("no-metadata"), delete: p.flags.contains("delete"), }; - pfs_ms::update_archive(Path::new(archive), Path::new(dir), &opts).map_err(|e| e.to_string()) + pfs_ms::update_archive(Path::new(archive), Path::new(dir), &opts).map_err(|e| e.to_string())?; + maybe_autosign(archive, p.values.get("key")) } fn cmd_extract(a: &[String]) -> CliResult { @@ -308,3 +344,62 @@ fn cmd_extract(a: &[String]) -> CliResult { pfs_ms::extract_archive(Path::new(archive), Path::new(dir), at, metadata) .map_err(|e| e.to_string()) } + +fn cmd_keygen(a: &[String]) -> CliResult { + let p = parse_flags(a, &[])?; + let priv_out = pos(&p, 0, "")?; + let pub_out = pos(&p, 1, "")?; + let s = pcf_sig_cli::keygen(priv_out, pub_out).map_err(|e| e.to_string())?; + println!( + "wrote private key {priv_out} and public key {pub_out}\nfingerprint {}", + pcf_sig_cli::hex(&s.fingerprint) + ); + Ok(()) +} + +fn cmd_sign(a: &[String]) -> CliResult { + let p = parse_flags(a, &["key"])?; + let file = pos(&p, 0, "")?; + let key = p + .values + .get("key") + .ok_or("missing required flag --key ")?; + let outcome = pfs_ms::sign_archive(Path::new(file), Path::new(key), p.flags.contains("resign")) + .map_err(|e| e.to_string())?; + match outcome.sig_partition_uid { + None => println!( + "nothing to sign ({} partition(s) already signed by this key)", + outcome.skipped_already_signed + ), + Some(uid) => { + let mut msg = format!( + "signed {} partition(s) into PCFSIG_SIG {}", + outcome.signed_uids.len(), + pcf_sig_cli::hex(&uid) + ); + if outcome.skipped_already_signed > 0 { + msg.push_str(&format!( + "; skipped {} already signed", + outcome.skipped_already_signed + )); + } + println!("{msg}"); + } + } + Ok(()) +} + +fn cmd_verify_sig(a: &[String]) -> CliResult { + let p = parse_flags(a, &["key"])?; + let file = pos(&p, 0, "")?; + let trusted = p.values.get("key").map(Path::new); + let recheck = !p.flags.contains("no-recheck"); + let summary = pcf_sig_cli::verify_file(file, trusted, recheck).map_err(|e| e.to_string())?; + print!("{}", pcf_sig_cli::format_verify(&summary)); + if !pcf_sig_cli::all_valid(&summary) + || (summary.trusted_fingerprint.is_some() && !summary.trusted_match) + { + return Err("signature verification failed".to_string()); + } + Ok(()) +} diff --git a/reference/PFS-MS-v1.0/src/error.rs b/reference/PFS-MS-v1.0/src/error.rs index e02a571..f70b74b 100644 --- a/reference/PFS-MS-v1.0/src/error.rs +++ b/reference/PFS-MS-v1.0/src/error.rs @@ -13,6 +13,9 @@ pub enum Error { Io(std::io::Error), /// A PCF container-level error (bad magic, hash mismatch, …). Pcf(pcf::Error), + /// A PCF-SIG signing error while committing a signature session + /// (see [`crate::sign_archive`]). + Signature(pcf_sig::Error), /// A Node Record was structurally invalid (bad magic/version/kind, a /// reserved flag bit set, an out-of-range or illegal name, a truncated @@ -70,6 +73,7 @@ impl fmt::Display for Error { match self { Error::Io(e) => write!(f, "io error: {e}"), Error::Pcf(e) => write!(f, "pcf error: {e}"), + Error::Signature(e) => write!(f, "signature error: {e}"), Error::MalformedNode(m) => write!(f, "malformed node record: {m}"), Error::MalformedSession(m) => write!(f, "malformed session record: {m}"), Error::BrokenChain(m) => write!(f, "broken session chain: {m}"), @@ -107,3 +111,9 @@ impl From for Error { Error::Pcf(e) } } + +impl From for Error { + fn from(e: pcf_sig::Error) -> Self { + Error::Signature(e) + } +} diff --git a/reference/PFS-MS-v1.0/src/lib.rs b/reference/PFS-MS-v1.0/src/lib.rs index 77223f6..9e0bab8 100644 --- a/reference/PFS-MS-v1.0/src/lib.rs +++ b/reference/PFS-MS-v1.0/src/lib.rs @@ -47,6 +47,7 @@ mod fs; mod node; mod reader; mod session; +mod sign; mod tree; mod vector; mod writer; @@ -59,6 +60,7 @@ pub use fs::FsReader; pub use node::{ContentSection, NodeRecord}; pub use reader::{build_node_view, scan, verify_chain, NodeView, Scan, SessionView}; pub use session::{member_blocks_digest, SessionRecord}; +pub use sign::{sign_archive, SignOutcome}; pub use tree::{build_tree, current_delta_depth, is_live, read_file, resolve_path, Tree}; pub use vector::build_reference_vector; pub use writer::{new_id, Change, FsWriter, Partition}; diff --git a/reference/PFS-MS-v1.0/src/sign.rs b/reference/PFS-MS-v1.0/src/sign.rs new file mode 100644 index 0000000..74322de --- /dev/null +++ b/reference/PFS-MS-v1.0/src/sign.rs @@ -0,0 +1,220 @@ +//! PFS-aware PCF-SIG signing (spec PFS-MS Section 15 / PCF-SIG Section 4). +//! +//! A PFS-MS file is append-only and its Table Blocks form a *backward-linked* +//! session chain terminated by a trailer. Adding signature partitions with the +//! generic PCF `add_partition` would splice a fresh block onto the oldest block +//! of that chain, which a PFS Reader then mis-reads as a stray session HEAD. +//! +//! Instead, signing commits the `PCFSIG_KEY` / `PCFSIG_SIG` partitions as a +//! dedicated **signature session**: they ride in a normal PFS session's HEAD +//! block, counted by `block_count` and committed by the head `table_hash`, so +//! the session chain stays valid and the file remains purely append-only. A PFS +//! Reader ignores the foreign partition types (they introduce no nodes); a +//! PCF-SIG Reader finds and verifies them by type. +//! +//! Coverage is **content + structure**: RAW file content and PFS_NODE records. +//! PFS_SESSION records are deliberately *not* covered — they are already +//! tamper-evident through PFS's own inter-session hash chain, and signing them +//! would never converge (each signature itself adds a new session). Signing is +//! incremental: partitions already covered by a valid signature from the same +//! key are skipped, so re-signing an unchanged file is a no-op. + +use std::io::Cursor; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +use pcf::Container; +use pcf_sig::{ + signed_entry_from_partition, verify_all, DataRecheck, EntryVerdict, KeyRecord, Manifest, + ManifestVerdict, SignaturePartition, SigningMaterial, TYPE_PCFSIG_KEY, TYPE_PCFSIG_SIG, +}; + +use crate::consts::{PFS_NODE_TYPE, RAW_TYPE}; +use crate::error::{Error, Result}; +use crate::writer::{new_id, FsWriter, Partition}; + +/// Length of the Ed25519 secret seed stored in a key file. +const SEED_LEN: usize = 32; + +/// Outcome of [`sign_archive`]. +#[derive(Debug, Clone)] +pub struct SignOutcome { + /// Partition uids covered by the signature committed in this call. + pub signed_uids: Vec<[u8; 16]>, + /// Uid of the new PCFSIG_SIG partition, or `None` when nothing was signed. + pub sig_partition_uid: Option<[u8; 16]>, + /// Eligible partitions skipped because they were already signed by this key. + pub skipped_already_signed: usize, +} + +/// Sign a PFS-MS file in place by committing a signature session. +/// +/// `key_path` holds a 32-byte Ed25519 secret seed (as written by +/// `pcf-sig keygen`). When `resign` is false, only RAW/PFS_NODE partitions not +/// already covered by a valid signature from this key are signed; when true, +/// every RAW/PFS_NODE partition is signed afresh. Returns a [`SignOutcome`] +/// whose `sig_partition_uid` is `None` if there was nothing new to sign. +pub fn sign_archive(path: &Path, key_path: &Path, resign: bool) -> Result { + let seed = read_seed(key_path)?; + let signer = SigningMaterial::ed25519_from_seed(&seed); + let fingerprint = signer.fingerprint(); + + // Phase 1 (read-only): enumerate partitions, find what this key already + // covers, and build the signature payload. Work on an in-memory copy so no + // write lock is held while we read. + let bytes = std::fs::read(path)?; + let mut container = Container::open(Cursor::new(bytes))?; + let entries = container.entries()?; + + // Candidate targets: file content and node records only (see module docs). + let mut candidates: Vec<[u8; 16]> = entries + .iter() + .filter(|e| e.partition_type == RAW_TYPE || e.partition_type == PFS_NODE_TYPE) + .map(|e| e.uid) + .collect(); + + let mut skipped_already_signed = 0usize; + if !resign { + let already = already_signed_by(&mut container, &fingerprint)?; + let before = candidates.len(); + candidates.retain(|u| !already.contains(u)); + skipped_already_signed = before - candidates.len(); + } + + if candidates.is_empty() { + return Ok(SignOutcome { + signed_uids: Vec::new(), + sig_partition_uid: None, + skipped_already_signed, + }); + } + + // Does a PCFSIG_KEY for this signer already exist? If so, do not duplicate + // it; the manifest references the key by fingerprint, not by partition uid. + let key_present = entries.iter().any(|e| { + e.partition_type == TYPE_PCFSIG_KEY + && container + .read_partition_data(e) + .ok() + .and_then(|d| KeyRecord::from_bytes(&d).ok()) + .map(|rec| rec.fingerprint == fingerprint) + .unwrap_or(false) + }); + + // Build the manifest over the chosen partitions and sign it. + let mut signed_entries = Vec::with_capacity(candidates.len()); + for uid in &candidates { + let e = entries + .iter() + .find(|e| &e.uid == uid) + .expect("candidate uid came from entries"); + signed_entries.push(signed_entry_from_partition(e)?); + } + let manifest_hash = signer + .sig_algo() + .required_manifest_hash() + .expect("Ed25519 binds a manifest hash"); + let manifest = Manifest::new( + signer.sig_algo(), + manifest_hash, + fingerprint, + now_unix_seconds(), + signed_entries, + ); + let manifest_bytes = manifest.to_bytes(); + let signature = signer.sign(&manifest_bytes); + let sig_payload = SignaturePartition { + manifest, + manifest_bytes, + signature, + trailer: Vec::new(), + }; + + let sig_uid = new_id(); + let mut parts = Vec::with_capacity(2); + if !key_present { + parts.push(Partition { + partition_type: TYPE_PCFSIG_KEY, + uid: new_id(), + label: label32("pcfkey"), + data: signer.to_key_record().to_bytes(), + }); + } + parts.push(Partition { + partition_type: TYPE_PCFSIG_SIG, + uid: sig_uid, + label: label32("pcfsig"), + data: sig_payload.to_bytes(), + }); + + // Phase 2: commit the signature session. Drop the in-memory reader first. + drop(container); + let f = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(path)?; + let mut w = FsWriter::open(f)?; + w.set_writer_id(b"pcf-sig"); + w.commit(parts, new_id(), 0, now_unix_ms(), b"pcf-sig")?; + w.into_storage().sync_all()?; + + Ok(SignOutcome { + signed_uids: candidates, + sig_partition_uid: Some(sig_uid), + skipped_already_signed, + }) +} + +/// Collect partition uids already covered by a valid signature from the key +/// with the given fingerprint. +fn already_signed_by( + container: &mut Container>>, + fingerprint: &[u8; 32], +) -> Result> { + let mut out = std::collections::HashSet::new(); + for report in verify_all(container, DataRecheck::Skip)? { + if report.verdict != ManifestVerdict::Valid || &report.signer_key_fingerprint != fingerprint + { + continue; + } + for entry in report.entries { + if entry.verdict == EntryVerdict::Valid { + out.insert(entry.uid); + } + } + } + Ok(out) +} + +fn read_seed(path: &Path) -> Result<[u8; SEED_LEN]> { + let bytes = std::fs::read(path)?; + if bytes.len() != SEED_LEN { + return Err(Error::InvalidPath("private key must be exactly 32 bytes")); + } + let mut seed = [0u8; SEED_LEN]; + seed.copy_from_slice(&bytes); + Ok(seed) +} + +/// Encode a short ASCII label into the fixed 32-byte PCF label field. +fn label32(s: &str) -> [u8; 32] { + let mut l = [0u8; 32]; + let b = s.as_bytes(); + let n = b.len().min(32); + l[..n].copy_from_slice(&b[..n]); + l +} + +fn now_unix_seconds() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +fn now_unix_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} diff --git a/reference/PFS-MS-v1.0/tests/sign.rs b/reference/PFS-MS-v1.0/tests/sign.rs new file mode 100644 index 0000000..2df8e4c --- /dev/null +++ b/reference/PFS-MS-v1.0/tests/sign.rs @@ -0,0 +1,141 @@ +//! PFS-aware signing: a signature rides in a dedicated PFS session, so the +//! session chain stays valid, signing is incremental, and a PCF-SIG verifier +//! accepts the result. + +use std::path::PathBuf; +use std::sync::atomic::{AtomicU32, Ordering}; + +use pcf::HashAlgo; +use pcf_sig::{EntryVerdict, ManifestVerdict}; +use pfs_ms::{sign_archive, FsReader, FsWriter}; + +static COUNTER: AtomicU32 = AtomicU32::new(0); + +fn tmp(suffix: &str) -> PathBuf { + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let mut p = std::env::temp_dir(); + p.push(format!( + "pfs-sign-test-{}-{}-{}", + std::process::id(), + n, + suffix + )); + p +} + +/// Write a 32-byte Ed25519 seed to a fresh key file. +fn keyfile(name: &str) -> PathBuf { + let p = tmp(name); + std::fs::write(&p, [7u8; 32]).unwrap(); + p +} + +/// Build a small PFS-MS file on disk with two files. +fn make_fs(path: &std::path::Path) { + let f = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(path) + .unwrap(); + let mut w = FsWriter::mkfs(f, HashAlgo::Sha256).unwrap(); + w.put_file("a.txt", b"hello").unwrap(); + w.put_file("b.txt", b"world").unwrap(); + w.into_storage().sync_all().unwrap(); +} + +fn open_fs(path: &std::path::Path) -> FsReader { + FsReader::open(std::fs::File::open(path).unwrap()).unwrap() +} + +#[test] +fn sign_keeps_chain_valid_and_verifies() { + let pcf = tmp("rt.pcf"); + make_fs(&pcf); + let key = keyfile("rt.key"); + + let out = sign_archive(&pcf, &key, false).unwrap(); + assert!(out.sig_partition_uid.is_some()); + assert!(!out.signed_uids.is_empty()); + + // PFS session-chain integrity survives the signature session. + open_fs(&pcf).verify().unwrap(); + // Files still readable. + assert_eq!(open_fs(&pcf).read_path("a.txt").unwrap(), b"hello"); + + // PCF-SIG verifier accepts the signature over the content/node partitions. + let v = pcf_sig_cli::verify_file(pcf.to_str().unwrap(), None, true).unwrap(); + assert_eq!(v.reports.len(), 1); + assert_eq!(v.reports[0].verdict, ManifestVerdict::Valid); + assert!(v.reports[0] + .entries + .iter() + .all(|e| e.verdict == EntryVerdict::Valid)); + + for p in [&pcf, &key] { + let _ = std::fs::remove_file(p); + } +} + +#[test] +fn signing_is_incremental_and_converges() { + let pcf = tmp("inc.pcf"); + make_fs(&pcf); + let key = keyfile("inc.key"); + + let first = sign_archive(&pcf, &key, false).unwrap(); + assert!(first.sig_partition_uid.is_some()); + + // Nothing changed: re-signing is a no-op (no new signature session). + let again = sign_archive(&pcf, &key, false).unwrap(); + assert!(again.sig_partition_uid.is_none()); + assert!(again.skipped_already_signed > 0); + + // Add a file, then sign only the new content/node. + { + let f = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&pcf) + .unwrap(); + let mut w = FsWriter::open(f).unwrap(); + w.put_file("c.txt", b"!!!").unwrap(); + w.into_storage().sync_all().unwrap(); + } + let third = sign_archive(&pcf, &key, false).unwrap(); + assert!(third.sig_partition_uid.is_some()); + assert!(third.skipped_already_signed > 0); + + // Chain still valid; two signatures now, both valid. + open_fs(&pcf).verify().unwrap(); + let v = pcf_sig_cli::verify_file(pcf.to_str().unwrap(), None, true).unwrap(); + assert_eq!(v.reports.len(), 2); + assert!(v + .reports + .iter() + .all(|r| r.verdict == ManifestVerdict::Valid)); + + for p in [&pcf, &key] { + let _ = std::fs::remove_file(p); + } +} + +#[test] +fn key_partition_is_deduplicated_across_signatures() { + let pcf = tmp("dedup.pcf"); + make_fs(&pcf); + let key = keyfile("dedup.key"); + + sign_archive(&pcf, &key, false).unwrap(); + // Force a second signature over the same partitions. + sign_archive(&pcf, &key, true).unwrap(); + + // Despite two signature sessions, only one PCFSIG_KEY exists. + let keys = pcf_sig_cli::list_keys(pcf.to_str().unwrap()).unwrap(); + assert_eq!(keys.len(), 1); + + for p in [&pcf, &key] { + let _ = std::fs::remove_file(p); + } +} diff --git a/tools/pcf-sig/Cargo.toml b/tools/pcf-sig/Cargo.toml new file mode 100644 index 0000000..4e6eeeb --- /dev/null +++ b/tools/pcf-sig/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "pcf-sig-cli" +version = "0.0.8" +edition = "2021" +rust-version = "1.75" +license = "MIT OR Apache-2.0" +description = "Command-line tool to sign and verify Partitioned Container Format (PCF) files with PCF-SIG signatures" +repository = "https://github.com/kduma-OSS/Partitioned-Container-Format" +homepage = "https://github.com/kduma-OSS/Partitioned-Container-Format" +readme = "README.md" +keywords = ["pcf", "pcf-sig", "ed25519", "signature", "container"] +categories = ["command-line-utilities", "cryptography"] + +# `pcf-sig-cli` is a thin command-line wrapper over the `pcf-sig` reference +# library. The orchestration logic (partition selection, incremental signing, +# report formatting) lives in the library half so the `pfs` CLI can delegate to +# exactly the same behaviour instead of duplicating it. + +[lib] +name = "pcf_sig_cli" +path = "src/lib.rs" + +[[bin]] +name = "pcf-sig" +path = "src/main.rs" + +[dependencies] +# Generic PCF container access (enumerate partitions, read/append). +pcf = { path = "../../reference/PCF-v1.0", version = "0.0.8" } + +# The PCF-SIG signing/verification primitives this tool drives. +pcf-sig = { path = "../../reference/PCF-SIG-v1.0", version = "0.0.8" } + +# UUIDv7 for the uids of newly written PCFSIG_SIG / PCFSIG_KEY partitions +# (consistent with the recommendation in both the PCF and PFS-MS specs). +uuid = { version = "1", features = ["v7"] } + +# 32 random bytes for the Ed25519 secret seed produced by `keygen`. Pure-Rust, +# no C dependencies; already present in the workspace dependency tree via uuid. +getrandom = "0.4" diff --git a/tools/pcf-sig/README.md b/tools/pcf-sig/README.md new file mode 100644 index 0000000..153efb4 --- /dev/null +++ b/tools/pcf-sig/README.md @@ -0,0 +1,87 @@ +# `pcf-sig` + +A command-line tool to **sign** and **verify** Partitioned Container Format +(PCF) files using the [PCF-SIG v1.0](../../reference/PCF-SIG-v1.0) cryptographic +signature profile (Ed25519). + +It is a thin CLI over the reference [`pcf-sig`](../../reference/PCF-SIG-v1.0) +crate. The orchestration (key generation, partition selection, incremental +signing, report formatting) lives in this crate's library half (`pcf_sig_cli`), +which the [`pfs`](../../reference/PFS-MS-v1.0) CLI reuses for its own `keygen` +and `verify-sig` subcommands. + +## Build & run + +From the repository root: + +```sh +cargo build -p pcf-sig-cli + +# generate a keypair (32-byte raw Ed25519 seed + public key) +cargo run -p pcf-sig-cli --bin pcf-sig -- keygen id.key id.pub + +# produce a sample PCF file +cargo run -p pcf --example gen_testvector -- /tmp/tv.pcf + +# sign every partition, then verify +cargo run -p pcf-sig-cli --bin pcf-sig -- sign /tmp/tv.pcf --key id.key +cargo run -p pcf-sig-cli --bin pcf-sig -- verify /tmp/tv.pcf --key id.pub +``` + +## Usage + +```text +pcf-sig keygen +pcf-sig sign --key [--uid ]... [--resign] [--sig-label ] [--key-label ] +pcf-sig verify [--key ] [--no-recheck] +pcf-sig keys +pcf-sig help | -h | --help +``` + +- **`keygen`** writes a 32-byte raw Ed25519 secret seed to `` (mode + `0600` on Unix) and the 32-byte raw public key to ``. It refuses to + overwrite existing files. +- **`sign`** adds a `PCFSIG_SIG` partition (and, if needed, a `PCFSIG_KEY`). + By default it covers every partition except existing PCF-SIG ones, and is + **incremental**: partitions already covered by a valid signature from the + same key are skipped, so re-signing an unchanged file is a no-op. `--resign` + forces all selected partitions to be signed afresh. `--uid` (repeatable) + restricts coverage to specific partition uids. +- **`verify`** reports, per signature, whether it is cryptographically valid + and whether each covered partition still matches. `--key` additionally + checks that a *trusted* public key matched a valid signature. The exit code + is non-zero if any signature is not fully valid (or a supplied trusted key + did not match). `--no-recheck` skips the independent data re-hash. +- **`keys`** lists the `PCFSIG_KEY` fingerprints embedded in the file. + +## Key files + +| File | Contents | +|--------------|-------------------------------------------| +| private key | 32 raw bytes — the Ed25519 secret seed | +| public key | 32 raw bytes — the raw Ed25519 public key | + +`verify` usually needs no public-key file: the signer's key is embedded in the +file's `PCFSIG_KEY` partition. Pass `--key ` only to assert *trust* in a +specific key out of band (fingerprint match). + +## PFS-MS files + +A PFS-MS archive is a PCF file, so `verify` and `keys` work on it directly. +**`sign` refuses PFS-MS files**, however: appending partitions would corrupt +their backward-linked session chain. Sign PFS-MS files with +[`pfs sign`](../../reference/PFS-MS-v1.0), which commits the signature as a +dedicated PFS session. + +## What it does *not* do + +- Implement algorithms other than Ed25519 (the PCF-SIG v1.0 MUST-support + baseline; other algorithm ids are registered but not implemented). +- Manage trust policy: it reports per-signature, per-partition facts, not an + aggregate "the file is trusted" verdict. + +## Tests + +```sh +cargo test -p pcf-sig-cli +``` diff --git a/tools/pcf-sig/src/lib.rs b/tools/pcf-sig/src/lib.rs new file mode 100644 index 0000000..59d8511 --- /dev/null +++ b/tools/pcf-sig/src/lib.rs @@ -0,0 +1,557 @@ +//! Shared command-line logic for signing and verifying PCF files with +//! PCF-SIG signatures. +//! +//! This library half is driven by two binaries: the standalone `pcf-sig` tool +//! (see `src/main.rs`) and the `pfs` CLI, which delegates its `keygen`, +//! `sign`, `verify-sig`, and per-operation auto-sign behaviour here so both +//! front-ends share one implementation. +//! +//! Everything operates on plain PCF containers. A PFS-MS file *is* a PCF file, +//! so [`verify_file`] and [`list_keys`] work on PFS archives unchanged; signing +//! a PFS archive, however, must be committed as a session ([`sign_file`] refuses +//! PFS files and the `pfs sign` subcommand handles them). + +use std::fmt; +use std::fs::{File, OpenOptions}; +use std::io::{Cursor, Read, Seek, Write}; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +use pcf::{Container, PartitionEntry, UID_SIZE}; +use pcf_sig::{ + compute_fingerprint, is_crypto_hash, sign_partitions, verify_all, verify_all_with_recheck, + DataRecheck, EntryVerdict, KeyRecord, ManifestVerdict, SignatureReport, TYPE_PCFSIG_KEY, + TYPE_PCFSIG_SIG, +}; + +/// The Ed25519 secret seed / raw public key length used by key files. +const ED25519_KEY_LEN: usize = 32; + +/// PFS-MS PFS_SESSION partition type. A file carrying one is a PFS-MS archive, +/// whose backward-linked session chain would be corrupted by appending raw +/// partitions; such files must be signed with `pfs sign` instead. +const TYPE_PFS_SESSION: u32 = 0xAAAA_0002; + +/// Errors surfaced by the CLI helpers, with messages suitable for printing +/// straight to stderr. +#[derive(Debug)] +pub enum CliError { + Io(std::io::Error), + Pcf(pcf::Error), + Sig(pcf_sig::Error), + Msg(String), +} + +impl fmt::Display for CliError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + CliError::Io(e) => write!(f, "{e}"), + CliError::Pcf(e) => write!(f, "{e}"), + CliError::Sig(e) => write!(f, "{e}"), + CliError::Msg(m) => write!(f, "{m}"), + } + } +} + +impl std::error::Error for CliError {} + +impl From for CliError { + fn from(e: std::io::Error) -> Self { + CliError::Io(e) + } +} +impl From for CliError { + fn from(e: pcf::Error) -> Self { + CliError::Pcf(e) + } +} +impl From for CliError { + fn from(e: pcf_sig::Error) -> Self { + CliError::Sig(e) + } +} + +/// Result alias for the CLI helpers. +pub type CliResult = Result; + +// ---- key generation ------------------------------------------------------- + +/// Outcome of [`keygen`]: the fingerprint of the freshly created key. +#[derive(Debug, Clone)] +pub struct KeygenSummary { + pub fingerprint: [u8; 32], +} + +/// Generate a fresh Ed25519 keypair and write the 32-byte secret seed and the +/// 32-byte raw public key to `priv_path` and `pub_path` respectively. +/// +/// Refuses to overwrite existing files so a key is never clobbered by mistake. +/// On Unix the private key file is created with mode `0600`. +pub fn keygen(priv_path: &str, pub_path: &str) -> CliResult { + if Path::new(priv_path).exists() { + return Err(CliError::Msg(format!( + "refusing to overwrite existing private key '{priv_path}'" + ))); + } + if Path::new(pub_path).exists() { + return Err(CliError::Msg(format!( + "refusing to overwrite existing public key '{pub_path}'" + ))); + } + + let mut seed = [0u8; ED25519_KEY_LEN]; + getrandom::fill(&mut seed).map_err(|e| CliError::Msg(format!("rng failure: {e}")))?; + + let signer = pcf_sig::SigningMaterial::ed25519_from_seed(&seed); + let public = signer.public_key_bytes(); + let fingerprint = signer.fingerprint(); + + write_private_key(priv_path, &seed)?; + std::fs::write(pub_path, &public)?; + + Ok(KeygenSummary { fingerprint }) +} + +#[cfg(unix)] +fn write_private_key(path: &str, seed: &[u8]) -> CliResult<()> { + use std::os::unix::fs::OpenOptionsExt; + let mut f = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path)?; + f.write_all(seed)?; + Ok(()) +} + +#[cfg(not(unix))] +fn write_private_key(path: &str, seed: &[u8]) -> CliResult<()> { + let mut f = OpenOptions::new().write(true).create_new(true).open(path)?; + f.write_all(seed)?; + Ok(()) +} + +/// Load a 32-byte Ed25519 secret seed from `path`. +fn read_seed(path: &str) -> CliResult<[u8; ED25519_KEY_LEN]> { + let bytes = std::fs::read(path)?; + if bytes.len() != ED25519_KEY_LEN { + return Err(CliError::Msg(format!( + "private key '{path}' must be exactly {ED25519_KEY_LEN} bytes (got {})", + bytes.len() + ))); + } + let mut seed = [0u8; ED25519_KEY_LEN]; + seed.copy_from_slice(&bytes); + Ok(seed) +} + +/// Load a 32-byte raw Ed25519 public key from `path`. +fn read_public(path: &Path) -> CliResult> { + let bytes = std::fs::read(path)?; + if bytes.len() != ED25519_KEY_LEN { + return Err(CliError::Msg(format!( + "public key '{}' must be exactly {ED25519_KEY_LEN} bytes (got {})", + path.display(), + bytes.len() + ))); + } + Ok(bytes) +} + +// ---- signing -------------------------------------------------------------- + +/// Outcome of [`sign_file`]. +#[derive(Debug, Clone)] +pub struct SignSummary { + /// Partitions covered by the signature written in this call. + pub signed_uids: Vec<[u8; UID_SIZE]>, + /// Uid of the new PCFSIG_SIG partition, or `None` when nothing was signed. + pub sig_partition_uid: Option<[u8; UID_SIZE]>, + /// Eligible partitions skipped because they were already signed by this key. + pub skipped_already_signed: usize, + /// Partitions skipped (in "sign all" mode) for lacking a cryptographic hash. + pub skipped_weak_hash: usize, +} + +/// Sign partitions of the PCF file at `pcf_path` with the Ed25519 key in +/// `key_path`, writing one PCFSIG_SIG partition (and a deduplicated +/// PCFSIG_KEY) into the file. +/// +/// * `select`: explicit list of partition uids to cover; `None` means "every +/// partition except existing PCFSIG_KEY / PCFSIG_SIG partitions". +/// * `resign`: when `false` (the default), partitions already covered by a +/// *valid* signature from this same key are skipped; signing is therefore +/// incremental and a no-op once everything is covered. When `true`, all +/// selected partitions are (re-)signed. +pub fn sign_file( + pcf_path: &str, + key_path: &str, + select: Option>, + resign: bool, + sig_label: &str, + key_label: &str, +) -> CliResult { + let seed = read_seed(key_path)?; + let signer = pcf_sig::SigningMaterial::ed25519_from_seed(&seed); + let fingerprint = signer.fingerprint(); + + let file = open_rw(pcf_path)?; + let mut container = Container::open(file)?; + let entries = container.entries()?; + + // Refuse to sign PFS-MS files here: appending partitions would break their + // session chain. The `pfs sign` subcommand signs them correctly (by + // committing a signature session). + if entries.iter().any(|e| e.partition_type == TYPE_PFS_SESSION) { + return Err(CliError::Msg( + "this looks like a PFS-MS file; use `pfs sign --key ` instead \ + (PFS signatures must be committed as a session, not appended)" + .into(), + )); + } + + let mut skipped_weak_hash = 0usize; + let mut candidates: Vec<[u8; UID_SIZE]> = match select { + Some(uids) => { + for u in &uids { + if !entries.iter().any(|e| &e.uid == u) { + return Err(CliError::Msg(format!( + "no partition with uid {} in '{pcf_path}'", + hex(u) + ))); + } + } + uids + } + None => entries + .iter() + .filter(|e| e.partition_type != TYPE_PCFSIG_KEY && e.partition_type != TYPE_PCFSIG_SIG) + .filter(|e| { + let ok = is_crypto_hash(e.data_hash_algo); + if !ok { + skipped_weak_hash += 1; + } + ok + }) + .map(|e| e.uid) + .collect(), + }; + + let mut skipped_already_signed = 0usize; + if !resign { + let already = signed_uids_for(&mut container, &fingerprint)?; + let before = candidates.len(); + candidates.retain(|u| !already.contains(u)); + skipped_already_signed = before - candidates.len(); + } + + if candidates.is_empty() { + return Ok(SignSummary { + signed_uids: Vec::new(), + sig_partition_uid: None, + skipped_already_signed, + skipped_weak_hash, + }); + } + + let sig_uid = new_uid(); + let key_uid = new_uid(); + let signed_at = now_unix_seconds(); + sign_partitions( + &mut container, + &signer, + &candidates, + sig_uid, + key_uid, + signed_at, + sig_label, + key_label, + )?; + container.into_storage().flush()?; + + Ok(SignSummary { + signed_uids: candidates, + sig_partition_uid: Some(sig_uid), + skipped_already_signed, + skipped_weak_hash, + }) +} + +/// Collect the uids already covered by a *valid* signature from the key with +/// the given fingerprint. +fn signed_uids_for( + container: &mut Container, + fingerprint: &[u8; 32], +) -> CliResult> { + let mut out = std::collections::HashSet::new(); + for report in verify_all(container, DataRecheck::Skip)? { + if report.verdict != ManifestVerdict::Valid || &report.signer_key_fingerprint != fingerprint + { + continue; + } + for entry in report.entries { + if entry.verdict == EntryVerdict::Valid { + out.insert(entry.uid); + } + } + } + Ok(out) +} + +// ---- verification --------------------------------------------------------- + +/// Outcome of [`verify_file`]. +#[derive(Debug, Clone)] +pub struct VerifySummary { + /// One report per PCFSIG_SIG partition found in the file. + pub reports: Vec, + /// Fingerprint of the trusted public key, if `--key` was supplied. + pub trusted_fingerprint: Option<[u8; 32]>, + /// Whether at least one valid signature matched `trusted_fingerprint`. + pub trusted_match: bool, +} + +/// Verify every PCFSIG_SIG partition in the PCF file at `pcf_path`. +/// +/// `trusted_pub`, if given, is a raw 32-byte Ed25519 public key: the result +/// records whether a valid signature from that exact key is present (an +/// out-of-band trust check on top of the in-file keys). `recheck` independently +/// re-hashes covered partition bytes when true (recommended). +pub fn verify_file( + pcf_path: &str, + trusted_pub: Option<&Path>, + recheck: bool, +) -> CliResult { + let trusted_fingerprint = match trusted_pub { + Some(p) => Some(compute_fingerprint(&read_public(p)?)), + None => None, + }; + + // Read-only: load into memory so no write permission is required. + let bytes = std::fs::read(pcf_path)?; + let mut container = Container::open(Cursor::new(bytes))?; + let reports = if recheck { + verify_all_with_recheck(&mut container)? + } else { + verify_all(&mut container, DataRecheck::Skip)? + }; + + let trusted_match = match trusted_fingerprint { + Some(fp) => reports + .iter() + .any(|r| r.verdict == ManifestVerdict::Valid && r.signer_key_fingerprint == fp), + None => false, + }; + + Ok(VerifySummary { + reports, + trusted_fingerprint, + trusted_match, + }) +} + +// ---- key listing ---------------------------------------------------------- + +/// One embedded PCFSIG_KEY partition. +#[derive(Debug, Clone)] +pub struct KeyInfo { + pub uid: [u8; UID_SIZE], + pub fingerprint: [u8; 32], + pub key_format_id: u8, +} + +/// List the PCFSIG_KEY partitions embedded in the PCF file at `pcf_path`. +pub fn list_keys(pcf_path: &str) -> CliResult> { + let bytes = std::fs::read(pcf_path)?; + let mut container = Container::open(Cursor::new(bytes))?; + let entries = container.entries()?; + let mut out = Vec::new(); + for e in &entries { + if e.partition_type != TYPE_PCFSIG_KEY { + continue; + } + if let Ok(rec) = KeyRecord::from_bytes(&container.read_partition_data(e)?) { + out.push(KeyInfo { + uid: e.uid, + fingerprint: rec.fingerprint, + key_format_id: rec.key_format.id(), + }); + } + } + Ok(out) +} + +// ---- formatting helpers --------------------------------------------------- + +/// Lowercase hex encoding of `bytes`. +pub fn hex(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push(char::from_digit((b >> 4) as u32, 16).unwrap()); + s.push(char::from_digit((b & 0x0f) as u32, 16).unwrap()); + } + s +} + +/// Parse a 16-byte (32 hex chars) partition uid. +pub fn parse_hex_uid(s: &str) -> CliResult<[u8; UID_SIZE]> { + let bytes = parse_hex(s)?; + if bytes.len() != UID_SIZE { + return Err(CliError::Msg(format!( + "uid must be {UID_SIZE} bytes ({} hex chars); got {}", + UID_SIZE * 2, + s.len() + ))); + } + let mut uid = [0u8; UID_SIZE]; + uid.copy_from_slice(&bytes); + Ok(uid) +} + +fn parse_hex(s: &str) -> CliResult> { + if s.len() % 2 != 0 { + return Err(CliError::Msg("hex string must have even length".into())); + } + let mut out = Vec::with_capacity(s.len() / 2); + let b = s.as_bytes(); + let mut i = 0; + while i < b.len() { + let hi = (b[i] as char) + .to_digit(16) + .ok_or_else(|| CliError::Msg(format!("invalid hex digit '{}'", b[i] as char)))?; + let lo = (b[i + 1] as char) + .to_digit(16) + .ok_or_else(|| CliError::Msg(format!("invalid hex digit '{}'", b[i + 1] as char)))?; + out.push(((hi << 4) | lo) as u8); + i += 2; + } + Ok(out) +} + +/// Render a [`SignSummary`] for human consumption. +pub fn format_sign(s: &SignSummary) -> String { + match s.sig_partition_uid { + None => { + let mut msg = "nothing to sign".to_string(); + if s.skipped_already_signed > 0 { + msg.push_str(&format!( + " ({} partition(s) already signed by this key)", + s.skipped_already_signed + )); + } + msg + } + Some(uid) => { + let mut msg = format!( + "signed {} partition(s) into PCFSIG_SIG {}", + s.signed_uids.len(), + hex(&uid) + ); + if s.skipped_already_signed > 0 { + msg.push_str(&format!( + "; skipped {} already signed", + s.skipped_already_signed + )); + } + if s.skipped_weak_hash > 0 { + msg.push_str(&format!( + "; skipped {} with non-cryptographic hash", + s.skipped_weak_hash + )); + } + msg + } + } +} + +/// Render a [`VerifySummary`] for human consumption. +pub fn format_verify(v: &VerifySummary) -> String { + let mut out = String::new(); + if v.reports.is_empty() { + out.push_str("no PCF-SIG signatures found\n"); + return out; + } + for r in &v.reports { + let verdict = match &r.verdict { + ManifestVerdict::Valid => "VALID".to_string(), + ManifestVerdict::Invalid => "INVALID".to_string(), + ManifestVerdict::Unverifiable(reason) => format!("UNVERIFIABLE ({reason:?})"), + }; + out.push_str(&format!( + "signature {} {} signer {} signed_at {}\n", + hex(&r.sig_partition_uid), + verdict, + hex(&r.signer_key_fingerprint), + r.signed_at_unix_seconds + )); + for e in &r.entries { + out.push_str(&format!(" partition {} {:?}\n", hex(&e.uid), e.verdict)); + } + } + if let Some(fp) = v.trusted_fingerprint { + out.push_str(&format!( + "trusted key {}: {}\n", + hex(&fp), + if v.trusted_match { + "MATCHED a valid signature" + } else { + "NOT matched by any valid signature" + } + )); + } + out +} + +/// Render a list of [`KeyInfo`] for human consumption. +pub fn format_keys(keys: &[KeyInfo]) -> String { + if keys.is_empty() { + return "no PCFSIG_KEY partitions found\n".to_string(); + } + let mut out = String::new(); + for k in keys { + out.push_str(&format!( + "key {} fingerprint {} key_format_id {}\n", + hex(&k.uid), + hex(&k.fingerprint), + k.key_format_id + )); + } + out +} + +/// `true` when every signature in the summary is cryptographically valid and +/// every covered partition entry is valid. Used to pick an exit code. +pub fn all_valid(v: &VerifySummary) -> bool { + !v.reports.is_empty() + && v.reports.iter().all(|r| { + r.verdict == ManifestVerdict::Valid + && r.entries.iter().all(|e| e.verdict == EntryVerdict::Valid) + }) +} + +// ---- internals ------------------------------------------------------------ + +fn open_rw(path: &str) -> CliResult { + OpenOptions::new() + .read(true) + .write(true) + .open(path) + .map_err(|e| CliError::Msg(format!("cannot open '{path}': {e}"))) +} + +fn new_uid() -> [u8; UID_SIZE] { + *uuid::Uuid::now_v7().as_bytes() +} + +fn now_unix_seconds() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// Convenience: does this partition entry carry a PCF-SIG type? Exposed for +/// callers that want to reason about a container's contents. +pub fn is_pcfsig_partition(e: &PartitionEntry) -> bool { + e.partition_type == TYPE_PCFSIG_KEY || e.partition_type == TYPE_PCFSIG_SIG +} diff --git a/tools/pcf-sig/src/main.rs b/tools/pcf-sig/src/main.rs new file mode 100644 index 0000000..0588e5d --- /dev/null +++ b/tools/pcf-sig/src/main.rs @@ -0,0 +1,196 @@ +//! `pcf-sig` — sign and verify PCF files with PCF-SIG (Ed25519) signatures. +//! +//! ```text +//! pcf-sig keygen +//! pcf-sig sign --key [--uid ]... [--resign] [--sig-label ] [--key-label ] +//! pcf-sig verify [--key ] [--no-recheck] +//! pcf-sig keys +//! ``` +//! +//! A PFS-MS archive is a PCF file, so `verify` and `keys` work on it directly; +//! `sign`, however, refuses PFS-MS files (use `pfs sign`, which commits a +//! signature session). Signatures cover partition content (uid, type, label, +//! used_bytes, data_hash) but not byte offsets, so `pcf-compact` preserves them. + +use std::path::Path; +use std::process::ExitCode; + +use pcf_sig_cli::{ + all_valid, format_keys, format_sign, format_verify, keygen, list_keys, parse_hex_uid, + sign_file, verify_file, CliResult, +}; + +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + let cmd = args.first().map(|s| s.as_str()).unwrap_or(""); + let rest = if args.is_empty() { + &args[0..0] + } else { + &args[1..] + }; + let result = match cmd { + "keygen" => cmd_keygen(rest), + "sign" => return finish(cmd_sign(rest)), + "verify" => return cmd_verify(rest), + "keys" => cmd_keys(rest), + "" | "help" | "-h" | "--help" => { + print_usage(); + return ExitCode::SUCCESS; + } + other => Err(pcf_sig_cli::CliError::Msg(format!( + "unknown command '{other}' (try `pcf-sig help`)" + ))), + }; + finish(result) +} + +fn finish(r: CliResult<()>) -> ExitCode { + match r { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("pcf-sig: {e}"); + ExitCode::FAILURE + } + } +} + +fn print_usage() { + eprintln!( + "usage:\n pcf-sig keygen \n pcf-sig sign --key [--uid ]... [--resign] [--sig-label ] [--key-label ]\n pcf-sig verify [--key ] [--no-recheck]\n pcf-sig keys " + ); +} + +/// Minimal argument model: positionals, boolean flags, single-value flags, and +/// the repeatable `--uid` flag. +struct Args { + positional: Vec, + bools: std::collections::HashSet, + values: std::collections::HashMap, + uids: Vec, +} + +fn parse(args: &[String], value_flags: &[&str], bool_flags: &[&str]) -> CliResult { + let mut out = Args { + positional: Vec::new(), + bools: std::collections::HashSet::new(), + values: std::collections::HashMap::new(), + uids: Vec::new(), + }; + let mut i = 0; + while i < args.len() { + let a = &args[i]; + if let Some(name) = a.strip_prefix("--") { + if name == "uid" { + let v = next_value(args, i, name)?; + out.uids.push(v); + i += 2; + } else if value_flags.contains(&name) { + let v = next_value(args, i, name)?; + out.values.insert(name.to_string(), v); + i += 2; + } else if bool_flags.contains(&name) { + out.bools.insert(name.to_string()); + i += 1; + } else { + return Err(pcf_sig_cli::CliError::Msg(format!("unknown flag --{name}"))); + } + } else { + out.positional.push(a.clone()); + i += 1; + } + } + Ok(out) +} + +fn next_value(args: &[String], i: usize, name: &str) -> CliResult { + args.get(i + 1) + .cloned() + .ok_or_else(|| pcf_sig_cli::CliError::Msg(format!("flag --{name} needs a value"))) +} + +fn positional<'a>(a: &'a Args, i: usize, what: &str) -> CliResult<&'a str> { + a.positional + .get(i) + .map(|s| s.as_str()) + .ok_or_else(|| pcf_sig_cli::CliError::Msg(format!("missing argument: {what}"))) +} + +fn cmd_keygen(args: &[String]) -> CliResult<()> { + let a = parse(args, &[], &[])?; + let priv_out = positional(&a, 0, "")?; + let pub_out = positional(&a, 1, "")?; + let s = keygen(priv_out, pub_out)?; + println!( + "wrote private key {priv_out} and public key {pub_out}\nfingerprint {}", + pcf_sig_cli::hex(&s.fingerprint) + ); + Ok(()) +} + +fn cmd_sign(args: &[String]) -> CliResult<()> { + let a = parse(args, &["key", "sig-label", "key-label"], &["resign"])?; + let file = positional(&a, 0, "")?; + let key = a + .values + .get("key") + .ok_or_else(|| pcf_sig_cli::CliError::Msg("missing required flag --key".into()))?; + let select = if a.uids.is_empty() { + None + } else { + let mut uids = Vec::with_capacity(a.uids.len()); + for u in &a.uids { + uids.push(parse_hex_uid(u)?); + } + Some(uids) + }; + let sig_label = a + .values + .get("sig-label") + .map(|s| s.as_str()) + .unwrap_or("pcfsig"); + let key_label = a + .values + .get("key-label") + .map(|s| s.as_str()) + .unwrap_or("pcfkey"); + let summary = sign_file( + file, + key, + select, + a.bools.contains("resign"), + sig_label, + key_label, + )?; + println!("{}", format_sign(&summary)); + Ok(()) +} + +fn cmd_verify(args: &[String]) -> ExitCode { + let result = (|| -> CliResult { + let a = parse(args, &["key"], &["no-recheck"])?; + let file = positional(&a, 0, "")?; + let trusted = a.values.get("key").map(Path::new); + let recheck = !a.bools.contains("no-recheck"); + let summary = verify_file(file, trusted, recheck)?; + print!("{}", format_verify(&summary)); + // Success only if every signature is fully valid and, when a trusted + // key was supplied, it matched. + Ok(all_valid(&summary) && (summary.trusted_fingerprint.is_none() || summary.trusted_match)) + })(); + match result { + Ok(true) => ExitCode::SUCCESS, + Ok(false) => ExitCode::FAILURE, + Err(e) => { + eprintln!("pcf-sig: {e}"); + ExitCode::FAILURE + } + } +} + +fn cmd_keys(args: &[String]) -> CliResult<()> { + let a = parse(args, &[], &[])?; + let file = positional(&a, 0, "")?; + let keys = list_keys(file)?; + print!("{}", format_keys(&keys)); + Ok(()) +} diff --git a/tools/pcf-sig/tests/cli_roundtrip.rs b/tools/pcf-sig/tests/cli_roundtrip.rs new file mode 100644 index 0000000..901d6a3 --- /dev/null +++ b/tools/pcf-sig/tests/cli_roundtrip.rs @@ -0,0 +1,324 @@ +//! End-to-end tests for the `pcf-sig-cli` library half: keygen, incremental +//! signing, verification, trust matching, and tamper detection — all driven +//! through real PCF files on disk. + +use std::io::{Seek, SeekFrom, Write}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU32, Ordering}; + +use pcf::{Container, HashAlgo, PartitionEntry}; +use pcf_sig::{EntryVerdict, ManifestVerdict}; +use pcf_sig_cli::{keygen, list_keys, sign_file, verify_file}; + +static COUNTER: AtomicU32 = AtomicU32::new(0); + +/// A unique scratch path under the OS temp dir. +fn tmp(suffix: &str) -> PathBuf { + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let mut p = std::env::temp_dir(); + p.push(format!( + "pcfsig-test-{}-{}-{}", + std::process::id(), + n, + suffix + )); + p +} + +/// Create a PCF file with `count` ordinary partitions named p0..pN. +fn make_pcf(path: &std::path::Path, count: u8) -> Vec<[u8; 16]> { + let f = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(path) + .unwrap(); + let mut c = Container::create(f).unwrap(); + let mut uids = Vec::new(); + for i in 0..count { + let mut uid = [0u8; 16]; + uid[0] = i + 1; + let data = vec![i; 32 + i as usize]; + c.add_partition( + 0x10 + i as u32, + uid, + &format!("p{i}"), + &data, + 0, + HashAlgo::Sha256, + ) + .unwrap(); + uids.push(uid); + } + c.into_storage().flush().unwrap(); + uids +} + +/// Append one more ordinary partition to an existing PCF file; returns its uid. +fn append_partition(path: &std::path::Path, idx: u8) -> [u8; 16] { + let f = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(path) + .unwrap(); + let mut c = Container::open(f).unwrap(); + let mut uid = [0u8; 16]; + uid[0] = idx + 1; + let data = vec![idx; 48]; + c.add_partition( + 0x10 + idx as u32, + uid, + &format!("p{idx}"), + &data, + 0, + HashAlgo::Sha256, + ) + .unwrap(); + c.into_storage().flush().unwrap(); + uid +} + +fn entries(path: &std::path::Path) -> Vec { + let f = std::fs::File::open(path).unwrap(); + Container::open(f).unwrap().entries().unwrap() +} + +#[test] +fn keygen_writes_two_32_byte_files() { + let sk = tmp("a.key"); + let pk = tmp("a.pub"); + let s = keygen(sk.to_str().unwrap(), pk.to_str().unwrap()).unwrap(); + assert_eq!(std::fs::read(&sk).unwrap().len(), 32); + assert_eq!(std::fs::read(&pk).unwrap().len(), 32); + assert_eq!(s.fingerprint.len(), 32); + // Refuses to overwrite. + assert!(keygen(sk.to_str().unwrap(), pk.to_str().unwrap()).is_err()); + let _ = std::fs::remove_file(&sk); + let _ = std::fs::remove_file(&pk); +} + +#[test] +fn sign_then_verify_roundtrip_with_trust() { + let sk = tmp("rt.key"); + let pk = tmp("rt.pub"); + keygen(sk.to_str().unwrap(), pk.to_str().unwrap()).unwrap(); + let pcf = tmp("rt.pcf"); + make_pcf(&pcf, 2); + + let s = sign_file( + pcf.to_str().unwrap(), + sk.to_str().unwrap(), + None, + false, + "pcfsig", + "pcfkey", + ) + .unwrap(); + assert_eq!(s.signed_uids.len(), 2); + assert!(s.sig_partition_uid.is_some()); + + let v = verify_file(pcf.to_str().unwrap(), Some(pk.as_path()), true).unwrap(); + assert_eq!(v.reports.len(), 1); + assert_eq!(v.reports[0].verdict, ManifestVerdict::Valid); + assert!(v.reports[0] + .entries + .iter() + .all(|e| e.verdict == EntryVerdict::Valid)); + assert!(v.trusted_match); + + // Exactly one PCFSIG_KEY partition was written. + assert_eq!(list_keys(pcf.to_str().unwrap()).unwrap().len(), 1); + + for p in [&sk, &pk, &pcf] { + let _ = std::fs::remove_file(p); + } +} + +#[test] +fn signing_is_incremental() { + let sk = tmp("inc.key"); + let pk = tmp("inc.pub"); + keygen(sk.to_str().unwrap(), pk.to_str().unwrap()).unwrap(); + let pcf = tmp("inc.pcf"); + make_pcf(&pcf, 2); + + // First pass signs both. + let s1 = sign_file( + pcf.to_str().unwrap(), + sk.to_str().unwrap(), + None, + false, + "pcfsig", + "pcfkey", + ) + .unwrap(); + assert_eq!(s1.signed_uids.len(), 2); + + // Second pass with no changes is a no-op. + let s2 = sign_file( + pcf.to_str().unwrap(), + sk.to_str().unwrap(), + None, + false, + "pcfsig", + "pcfkey", + ) + .unwrap(); + assert!(s2.sig_partition_uid.is_none()); + assert_eq!(s2.skipped_already_signed, 2); + + // Add a partition: only the new one gets signed. + append_partition(&pcf, 2); + let s3 = sign_file( + pcf.to_str().unwrap(), + sk.to_str().unwrap(), + None, + false, + "pcfsig", + "pcfkey", + ) + .unwrap(); + assert_eq!(s3.signed_uids.len(), 1); + assert_eq!(s3.skipped_already_signed, 2); + + // Two signatures now; both valid. + let v = verify_file(pcf.to_str().unwrap(), None, true).unwrap(); + assert_eq!(v.reports.len(), 2); + assert!(v + .reports + .iter() + .all(|r| r.verdict == ManifestVerdict::Valid)); + + // --resign covers everything again in one fresh signature. + let s4 = sign_file( + pcf.to_str().unwrap(), + sk.to_str().unwrap(), + None, + true, + "pcfsig", + "pcfkey", + ) + .unwrap(); + assert_eq!(s4.signed_uids.len(), 3); + + for p in [&sk, &pk, &pcf] { + let _ = std::fs::remove_file(p); + } +} + +#[test] +fn tampered_partition_is_detected() { + let sk = tmp("tmp.key"); + let pk = tmp("tmp.pub"); + keygen(sk.to_str().unwrap(), pk.to_str().unwrap()).unwrap(); + let pcf = tmp("tmp.pcf"); + let uids = make_pcf(&pcf, 2); + sign_file( + pcf.to_str().unwrap(), + sk.to_str().unwrap(), + None, + false, + "pcfsig", + "pcfkey", + ) + .unwrap(); + + // Corrupt the first partition's data bytes in place (without touching the + // table entry's recorded data_hash). + let target = entries(&pcf) + .into_iter() + .find(|e| e.uid == uids[0]) + .unwrap(); + { + let mut f = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&pcf) + .unwrap(); + f.seek(SeekFrom::Start(target.start_offset)).unwrap(); + f.write_all(&[0xFF]).unwrap(); + f.flush().unwrap(); + } + + let v = verify_file(pcf.to_str().unwrap(), Some(pk.as_path()), true).unwrap(); + let entry = v.reports[0] + .entries + .iter() + .find(|e| e.uid == uids[0]) + .unwrap(); + assert_eq!(entry.verdict, EntryVerdict::DataHashRecomputationMismatch); + + for p in [&sk, &pk, &pcf] { + let _ = std::fs::remove_file(p); + } +} + +#[test] +fn refuses_to_sign_pfs_files() { + let sk = tmp("pfs.key"); + let pk = tmp("pfs.pub"); + keygen(sk.to_str().unwrap(), pk.to_str().unwrap()).unwrap(); + + // A PCF file carrying a PFS_SESSION-typed partition looks like a PFS-MS + // archive; signing it by appending partitions would corrupt its chain. + let pcf = tmp("pfs.pcf"); + { + let f = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(&pcf) + .unwrap(); + let mut c = Container::create(f).unwrap(); + c.add_partition(0xAAAA_0002, [9u8; 16], "session", b"x", 0, HashAlgo::Sha256) + .unwrap(); + c.into_storage().flush().unwrap(); + } + + let err = sign_file( + pcf.to_str().unwrap(), + sk.to_str().unwrap(), + None, + false, + "s", + "k", + ) + .unwrap_err(); + assert!(err.to_string().contains("PFS-MS")); + + for p in [&sk, &pk, &pcf] { + let _ = std::fs::remove_file(p); + } +} + +#[test] +fn wrong_trusted_key_does_not_match() { + let sk = tmp("w1.key"); + let pk = tmp("w1.pub"); + keygen(sk.to_str().unwrap(), pk.to_str().unwrap()).unwrap(); + let other_pk = tmp("w2.pub"); + let other_sk = tmp("w2.key"); + keygen(other_sk.to_str().unwrap(), other_pk.to_str().unwrap()).unwrap(); + + let pcf = tmp("w.pcf"); + make_pcf(&pcf, 1); + sign_file( + pcf.to_str().unwrap(), + sk.to_str().unwrap(), + None, + false, + "pcfsig", + "pcfkey", + ) + .unwrap(); + + let v = verify_file(pcf.to_str().unwrap(), Some(other_pk.as_path()), true).unwrap(); + assert!(v.reports[0].verdict == ManifestVerdict::Valid); + assert!(!v.trusted_match); + + for p in [&sk, &pk, &other_pk, &other_sk, &pcf] { + let _ = std::fs::remove_file(p); + } +}