Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ imagesize = "0.13"
# linger in freed memory, swap, or a core dump. Already in the tree transitively
# via ed25519-dalek; pinned here as a direct dependency.
zeroize = "1"
# Current UTC time for verify's default --now reference. Already in the tree
# via entangled-core; pinned here for the system-clock default.
time = { version = "0.3", default-features = false, features = ["std"] }

[profile.release]
strip = true
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ Runs the document through the `entangled-core` pipeline and prints the verdict,

A manifest is driven through the full chain: signature (Stages 2-6), canary (Stage 8), origin binding (Stage 9), and content index (Stage 9b). The stages that need out-of-band context run only when it is supplied:

- `--now` sets the verified-time reference for the canary and origin-expiry checks (defaults to the corpus clock).
- `--now` sets the verified-time reference for the canary and origin-expiry checks (defaults to the current system clock).
- `--fetched-onion` is the onion address the manifest was fetched from; with it, Stage 9 origin binding runs. Omit to skip Stage 9.
- `--content-index` is the served `/content_index.json`; when the manifest declares `content_root`, Stage 9b verifies it (and its absence with a declared `content_root` surfaces the fetch failure).
- `--expected-runtime-pubkey` is the manifest's `canary.runtime_pubkey`; for a content or transaction document, it is the key the signature is checked against. Without it, a content/transaction has no authorized key and reports a signature rejection.
Expand Down
2 changes: 1 addition & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ pub struct VerifyArgs {

/// Verified-time reference for the canary and origin-expiry checks,
/// RFC 3339 (YYYY-MM-DDTHH:MM:SSZ). A real client supplies its trusted
/// wall clock. Defaults to the corpus clock if omitted.
/// wall clock. Defaults to the current system UTC clock if omitted.
#[arg(long)]
pub now: Option<String>,

Expand Down
6 changes: 3 additions & 3 deletions src/commands/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ use entangled_core::document::{
use entangled_core::types::timestamp::EntangledTimestamp;

use crate::cli::{BuildArgs, DocKind};
use crate::commands::{resolve_seed, Error};
use crate::commands::{resolve_seed, Error, Outcome};

pub fn run(args: BuildArgs) -> Result<(), Error> {
pub fn run(args: BuildArgs) -> Result<Outcome, Error> {
let raw = std::fs::read(&args.input)
.map_err(|e| format!("cannot read {}: {e}", args.input.display()))?;
// A signing key is mandatory for build; no fresh-entropy fallback.
Expand Down Expand Up @@ -64,5 +64,5 @@ pub fn run(args: BuildArgs) -> Result<(), Error> {
let text = String::from_utf8(signed_bytes)
.map_err(|e| format!("internal: signed document is not UTF-8: {e}"))?;
println!("{text}");
Ok(())
Ok(Outcome::Success)
}
6 changes: 3 additions & 3 deletions src/commands/content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ use entangled_core::types::path::EntangledPath;
use entangled_core::types::timestamp::EntangledTimestamp;

use crate::cli::ContentArgs;
use crate::commands::Error;
use crate::commands::{Error, Outcome};
use crate::markdown;

pub fn run(args: ContentArgs) -> Result<(), Error> {
pub fn run(args: ContentArgs) -> Result<Outcome, Error> {
let source = std::fs::read_to_string(&args.markdown)
.map_err(|e| format!("cannot read {}: {e}", args.markdown.display()))?;

Expand Down Expand Up @@ -51,5 +51,5 @@ pub fn run(args: ContentArgs) -> Result<(), Error> {
let json = serde_json::to_string_pretty(&doc)
.map_err(|e| format!("failed to serialize the content document: {e}"))?;
println!("{json}");
Ok(())
Ok(Outcome::Success)
}
8 changes: 4 additions & 4 deletions src/commands/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use std::fs;
use std::path::Path;

use crate::cli::InitArgs;
use crate::commands::Error;
use crate::commands::{Error, Outcome};

/// Starter unsigned-manifest template. Placeholders are spelled out so the
/// publisher knows exactly which `keygen` output each field expects. It is not
Expand Down Expand Up @@ -39,9 +39,9 @@ const MANIFEST_TEMPLATE: &str = r#"{
}
"#;

const README_TEMPLATE: &str = "# Entangled site\n\nScaffolded by entangled-tool. Next steps:\n\n1. Run `entangled-tool keygen publisher`, `keygen runtime`, and `keygen origin`; store the seeds offline.\n2. Fill the REPLACE_WITH_ placeholders in `manifest.unsigned.json` with the printed public keys and onion address, and set the canary and updated times.\n3. Sign it: `entangled-tool build manifest --input manifest.unsigned.json --key-seed-hex <publisher seed> --now <current time>`.\n4. Add content documents under `content/` and sign each with `build content`.\n";
const README_TEMPLATE: &str = "# Entangled site\n\nScaffolded by entangled-tool. Next steps:\n\n1. Run `entangled-tool keygen publisher`, `keygen runtime`, and `keygen origin`; store each printed seed in a file (e.g. `publisher.seed`), kept offline with restrictive permissions.\n2. Fill the REPLACE_WITH_ placeholders in `manifest.unsigned.json` with the printed public keys and onion address, and set the canary and updated times.\n3. Sign it: `entangled-tool build manifest --input manifest.unsigned.json --key-seed-file publisher.seed --now <current time>`.\n4. Add content documents under `content/` and sign each with `build content --key-seed-file runtime.seed`.\n";

pub fn run(args: InitArgs) -> Result<(), Error> {
pub fn run(args: InitArgs) -> Result<Outcome, Error> {
let dir = &args.dir;
create_dir(dir)?;
create_dir(&dir.join("content"))?;
Expand All @@ -55,7 +55,7 @@ pub fn run(args: InitArgs) -> Result<(), Error> {
);
println!(" content/ (add content documents here)");
println!(" README.md (next steps)");
Ok(())
Ok(Outcome::Success)
}

fn create_dir(path: &Path) -> Result<(), Error> {
Expand Down
6 changes: 3 additions & 3 deletions src/commands/keygen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ use entangled_core::crypto::{
use entangled_core::types::manifest::OnionAddress;

use crate::cli::{KeyRole, KeygenArgs};
use crate::commands::{resolve_seed, seed_to_hex, Error};
use crate::commands::{resolve_seed, seed_to_hex, Error, Outcome};

pub fn run(args: KeygenArgs) -> Result<(), Error> {
pub fn run(args: KeygenArgs) -> Result<Outcome, Error> {
// A file or inline hex if supplied; otherwise fresh OS entropy.
let seed = resolve_seed(
args.seed_file.as_deref(),
Expand Down Expand Up @@ -57,5 +57,5 @@ pub fn run(args: KeygenArgs) -> Result<(), Error> {
);
}
}
Ok(())
Ok(Outcome::Success)
}
18 changes: 16 additions & 2 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! Subcommand implementations.
//!
//! Each module exposes a single `run(args) -> Result<(), Error>` entry point
//! invoked by the dispatcher in `main`.
//! Each module exposes a single `run(args) -> Result<Outcome, Error>` entry
//! point invoked by the dispatcher in `main`.

pub mod build;
pub mod content;
Expand All @@ -17,6 +17,20 @@ use zeroize::Zeroizing;
/// any underlying error (I/O, parsing, a core-library `Diagnostic`) uniformly.
pub type Error = Box<dyn std::error::Error>;

/// The outcome a subcommand reports to the process exit code. Distinct from
/// `Err`: a command can complete normally (no internal error) yet report a
/// negative result the caller must see - notably `verify` rejecting a document,
/// which must not exit 0 so CI and scripts treat an invalid document as a
/// failure.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome {
/// The command did what it was asked; exit 0.
Success,
/// The command ran but the result is negative (e.g. verify rejected the
/// document). The command has already reported the details; exit non-zero.
Rejected,
}

/// A 32-byte key seed that is zeroed when dropped, so secret material does not
/// linger in freed memory, swap, or a core dump.
pub(crate) type Seed = Zeroizing<[u8; 32]>;
Expand Down
87 changes: 63 additions & 24 deletions src/commands/verify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
//! 9b). The stages that need out-of-band context run only when that context is
//! supplied: `--fetched-onion` for origin binding and `--content-index` for the
//! content index. A skipped stage is reported, never silently passed. A reject
//! prints the diagnostic code, stage, and message and stops at the first
//! failing stage.
//! prints the diagnostic code, stage, and message, stops at the first failing
//! stage, and makes the process exit non-zero.
//!
//! Content and transaction documents are verified through signature only here;
//! their later binding checks need the fetch path / submit body, which a future
//! revision will accept.
//! Content and transaction documents are verified against the runtime key the
//! manifest authorizes, supplied with `--expected-runtime-pubkey`; their
//! later binding checks (fetch path / submit body) are not yet wired here.

use entangled_core::document::{
parse_and_verify_content, parse_and_verify_manifest, parse_and_verify_transaction,
Expand All @@ -22,17 +22,12 @@ use entangled_core::types::timestamp::EntangledTimestamp;
use entangled_core::validation::Diagnostic;

use crate::cli::VerifyArgs;
use crate::commands::Error;
use crate::commands::{Error, Outcome};

/// The corpus clock, used as the default verified-time reference when `--now`
/// is omitted so the common case (verifying a corpus document) just works.
const DEFAULT_NOW: &str = "2026-05-07T00:01:00Z";

pub fn run(args: VerifyArgs) -> Result<(), Error> {
pub fn run(args: VerifyArgs) -> Result<Outcome, Error> {
let bytes = std::fs::read(&args.input)
.map_err(|e| format!("cannot read {}: {e}", args.input.display()))?;
let now = EntangledTimestamp::try_from(args.now.as_deref().unwrap_or(DEFAULT_NOW))
.map_err(|e| format!("--now is not a valid RFC 3339 timestamp: {e}"))?;
let now = resolve_now(args.now.as_deref())?;

// Discriminate the document kind cheaply from the wire bytes so the runner
// can drive the right pipeline. The core parser re-checks this in Stage 4.
Expand All @@ -45,7 +40,49 @@ pub fn run(args: VerifyArgs) -> Result<(), Error> {
}
}

fn verify_manifest(args: &VerifyArgs, bytes: &[u8], now: &EntangledTimestamp) -> Result<(), Error> {
/// The verified-time reference for the canary and origin-expiry checks: the
/// `--now` value when given (for reproducibility), otherwise the current system
/// UTC clock. A real client uses its own trusted clock; defaulting to "now"
/// avoids a stale fixed date silently passing an expired canary.
fn resolve_now(arg: Option<&str>) -> Result<EntangledTimestamp, Error> {
match arg {
Some(s) => EntangledTimestamp::try_from(s)
.map_err(|e| format!("--now is not a valid RFC 3339 timestamp: {e}").into()),
None => {
let now = time::OffsetDateTime::now_utc();
// Format to the strict YYYY-MM-DDTHH:MM:SSZ shape the type accepts.
let s = format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
now.year(),
u8::from(now.month()),
now.day(),
now.hour(),
now.minute(),
now.second(),
);
eprintln!("note: no --now given; using the current system clock ({s})");
EntangledTimestamp::try_from(s.as_str())
.map_err(|e| format!("internal: bad system timestamp {s}: {e}").into())
}
}
}

fn verify_manifest(
args: &VerifyArgs,
bytes: &[u8],
now: &EntangledTimestamp,
) -> Result<Outcome, Error> {
// Stage 9b runs only inside Stage 9 (origin binding), which needs the
// fetched onion. Reject the misleading combination up front rather than
// silently ignoring --content-index.
if args.content_index.is_some() && args.fetched_onion.is_none() {
return Err(
"--content-index requires --fetched-onion: the content index check (Stage 9b) \
runs only after origin binding (Stage 9)"
.into(),
);
}

// Stage 2-6: signature.
let sig_verified = match parse_and_verify_manifest(bytes, now) {
Ok(v) => v,
Expand Down Expand Up @@ -92,28 +129,28 @@ fn verify_manifest(args: &VerifyArgs, bytes: &[u8], now: &EntangledTimestamp) ->
println!("verdict: accept");
println!("canary_state: {canary_state:?}");
report_skips(args);
Ok(())
Ok(Outcome::Success)
}

fn verify_content(args: &VerifyArgs, bytes: &[u8]) -> Result<(), Error> {
fn verify_content(args: &VerifyArgs, bytes: &[u8]) -> Result<Outcome, Error> {
let (runtime_pk, has_key) = runtime_key(args)?;
match parse_and_verify_content(bytes, &runtime_pk) {
Ok(_) => {
println!("verdict: accept");
print_runtime_note(has_key);
Ok(())
Ok(Outcome::Success)
}
Err(d) => report_reject(&d),
}
}

fn verify_transaction(args: &VerifyArgs, bytes: &[u8]) -> Result<(), Error> {
fn verify_transaction(args: &VerifyArgs, bytes: &[u8]) -> Result<Outcome, Error> {
let (runtime_pk, has_key) = runtime_key(args)?;
match parse_and_verify_transaction(bytes, &runtime_pk, None) {
Ok(_) => {
println!("verdict: accept");
print_runtime_note(has_key);
Ok(())
Ok(Outcome::Success)
}
Err(d) => report_reject(&d),
}
Expand Down Expand Up @@ -143,21 +180,23 @@ fn print_runtime_note(has_key: bool) {
}
}

fn report_reject(diag: &Diagnostic) -> Result<(), Error> {
fn report_reject(diag: &Diagnostic) -> Result<Outcome, Error> {
println!("verdict: reject");
println!("diagnostic: {}", diag.code);
println!("stage: {}", diag.stage);
println!("message: {}", diag.message);
Ok(())
Ok(Outcome::Rejected)
}

/// Print which optional manifest stages were skipped for lack of context, so an
/// accept verdict is never mistaken for a full-pipeline pass.
fn report_skips(args: &VerifyArgs) {
if args.fetched_onion.is_none() {
println!("note: Stage 9 origin binding skipped (no --fetched-onion)");
}
if args.content_index.is_none() {
// Without the fetched onion, Stage 9 (and so Stage 9b) does not run.
println!(
"note: Stage 9 origin binding and Stage 9b content index skipped (no --fetched-onion)"
);
} else if args.content_index.is_none() {
println!("note: Stage 9b content index skipped unless content_root forced a fetch failure (no --content-index)");
}
}
Expand Down
6 changes: 5 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ mod markdown;

use clap::Parser;
use cli::{Cli, Command};
use commands::Outcome;
use std::process::ExitCode;

fn main() -> ExitCode {
Expand All @@ -19,7 +20,10 @@ fn main() -> ExitCode {
Command::Content(args) => commands::content::run(args),
};
match result {
Ok(()) => ExitCode::SUCCESS,
Ok(Outcome::Success) => ExitCode::SUCCESS,
// The command already printed the negative result (e.g. verify's
// `verdict: reject`); exit non-zero so callers can detect it.
Ok(Outcome::Rejected) => ExitCode::FAILURE,
Err(e) => {
eprintln!("error: {e}");
ExitCode::FAILURE
Expand Down
Loading
Loading