#![forbid(unsafe_code)]
#![deny(missing_docs)]
use std::collections::BTreeMap;
use std::error::Error as StdError;
use std::fmt;
use std::fmt::Write as _;
use std::fs;
use std::io::{self, Write};
use std::process;
use std::str::FromStr;
use camino::{Utf8Path, Utf8PathBuf};
use clap::{Args, Parser, Subcommand};
use quantum_sign::crypto as qs_crypto;
use quantum_sign::crypto::{DigestAlg, HmacSha512Drbg};
use quantum_sign::format::QSig;
use quantum_sign::policy as qs_policy;
use quantum_sign::policy::{Policy, ValidationError};
use serde::Serialize;
use sha2::{Digest, Sha256, Sha512};
use sha3::{
digest::{ExtendableOutput, XofReader},
Shake256,
};
#[path = "../quorum.rs"]
mod quorum;
const MAX_SIGNERS: usize = 16;
const EXIT_OK: i32 = 0;
const EXIT_CRYPTO: i32 = 2;
const EXIT_POLICY: i32 = 3;
const EXIT_IO: i32 = 4;
use quorum::{
load_intent, make_fragment, quorum_add, quorum_init, quorum_seal, save_fragment,
validate_required_signatures, Intent,
};
#[derive(Parser, Debug)]
#[command(name = "quantum-sign", version, about)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
Keygen {
#[arg(long, default_value = "mldsa-87")]
profile: String,
#[arg(long)]
secret: Utf8PathBuf,
#[arg(long)]
public: Utf8PathBuf,
},
Sign {
#[arg(long)]
inp: Utf8PathBuf,
#[arg(long)]
sig: Utf8PathBuf,
#[arg(long, default_value = "sha512")]
digest: String,
#[arg(long)]
policy: Option<Utf8PathBuf>,
#[arg(long, default_value_t = false)]
allow_nonfips: bool,
#[arg(long, default_value_t = false)]
allow_missing_policy: bool,
#[arg(long, default_value_t = false)]
allow_unsafe_inputs: bool,
#[arg(long = "allowed-kid")]
allowed_kid: Vec<String>,
#[arg(long)]
display_name: Option<String>,
#[arg(long)]
creator: Option<String>,
},
Verify {
#[arg(long)]
inp: Utf8PathBuf,
#[arg(long)]
sig: Utf8PathBuf,
#[arg(long)]
trustdb: Option<Utf8PathBuf>,
#[arg(long, default_value_t = false)]
json: bool,
},
Timestamp {
#[command(subcommand)]
action: TimestampAction,
},
Log {
#[command(subcommand)]
action: LogAction,
},
Trust {
#[command(subcommand)]
action: TrustAction,
},
Quorum {
#[command(subcommand)]
action: QuorumAction,
},
Man,
}
#[derive(Subcommand, Debug)]
enum QuorumAction {
Init {
#[arg(long)]
artifact: Utf8PathBuf,
#[arg(long)]
policy: Utf8PathBuf,
#[arg(long, default_value = "sha512")]
digest: String,
#[arg(long)]
intent: Option<Utf8PathBuf>,
#[arg(long = "allowed-kid")]
allowed_kid: Vec<String>,
#[arg(long)]
display_name: Option<String>,
#[arg(long)]
creator: Option<String>,
},
Add {
#[arg(long)]
part: Utf8PathBuf,
#[arg(long)]
fragment: Utf8PathBuf,
},
Cosign {
#[arg(long)]
intent: Utf8PathBuf,
#[arg(long)]
secret: Utf8PathBuf,
#[arg(long)]
public: Utf8PathBuf,
#[arg(long)]
fragment: Utf8PathBuf,
#[arg(long)]
append: Option<Utf8PathBuf>,
},
Seal {
#[arg(long)]
part: Utf8PathBuf,
#[arg(long)]
out: Utf8PathBuf,
#[arg(long)]
trust_dir: Utf8PathBuf,
},
Package {
#[arg(long)]
artifact: Utf8PathBuf,
#[arg(long)]
policy: Utf8PathBuf,
#[arg(long, default_value = "sha512")]
digest: String,
#[arg(long)]
secret: Option<Utf8PathBuf>,
#[arg(long)]
public: Option<Utf8PathBuf>,
#[arg(long, default_value = "release")]
out_dir: Utf8PathBuf,
#[arg(long, default_value_t = true)]
zip: bool,
#[arg(long)]
display_name: Option<String>,
#[arg(long)]
creator: Option<String>,
},
}
#[derive(Subcommand, Debug)]
enum TrustAction {
Import {
#[arg(long)]
public: Utf8PathBuf,
#[arg(long, default_value = "./trust")]
trustdb: Utf8PathBuf,
},
List {
#[arg(long, default_value = "./trust")]
trustdb: Utf8PathBuf,
},
}
fn main() {
let code = match run() {
Ok(()) => EXIT_OK,
Err(err) => {
eprintln!("error: {err}");
match err {
CliError::Crypto(_) => EXIT_CRYPTO,
CliError::Io(_) => EXIT_IO,
CliError::PolicyRequired
| CliError::PolicyLoad(_)
| CliError::PolicyValidation(_)
| CliError::UnsupportedDigest(_)
| CliError::InvalidArgument(_)
| CliError::UnsafePath(_)
| CliError::Other(_) => EXIT_POLICY,
}
}
};
process::exit(code);
}
fn run() -> Result<(), CliError> {
let cli = Cli::parse();
match cli.command {
Commands::Man => {
const CLI_MAN: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/CLI.md"));
println!("{}", CLI_MAN);
Ok(())
}
Commands::Keygen {
profile,
secret,
public,
} => handle_keygen(&profile, secret, public),
Commands::Sign {
inp,
sig,
digest,
policy,
allow_nonfips,
allow_missing_policy,
allow_unsafe_inputs,
allowed_kid,
display_name,
creator,
} => handle_sign(
inp,
sig,
digest,
policy,
allow_nonfips,
allow_missing_policy,
allow_unsafe_inputs,
allowed_kid,
display_name,
creator,
),
Commands::Verify {
inp,
sig,
trustdb,
json,
} => handle_verify(inp, sig, trustdb, json),
Commands::Timestamp { action } => handle_timestamp(action),
Commands::Log { action } => handle_log(action),
Commands::Trust { action } => handle_trust(action),
Commands::Quorum { action } => handle_quorum(action),
}
}
fn handle_keygen(profile: &str, secret: Utf8PathBuf, public: Utf8PathBuf) -> Result<(), CliError> {
if profile != "mldsa-87" {
return Err(CliError::InvalidArgument("only mldsa-87 is supported"));
}
ensure_output_parent(&secret)?;
ensure_output_parent(&public)?;
let mut drbg = HmacSha512Drbg::from_os(Some(b"qs-keygen"))?;
let keypair = qs_crypto::keypair_mldsa87(&mut drbg)?;
let mut sk_file = create_exclusive(&secret)?;
sk_file.write_all(&keypair.secret)?;
let mut pk_file = create_exclusive(&public)?;
pk_file.write_all(&keypair.public)?;
let kid = qs_crypto::kid_from_public_key(&keypair.public)?;
println!("generated mldsa-87 keypair\n secret: {secret}\n public: {public}\n kid: {kid}");
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn handle_sign(
artifact: Utf8PathBuf,
sig_out: Utf8PathBuf,
digest_alg: String,
policy_path: Option<Utf8PathBuf>,
allow_nonfips: bool,
allow_missing_policy: bool,
allow_unsafe_inputs: bool,
allowed_kids: Vec<String>,
display_name: Option<String>,
creator: Option<String>,
) -> Result<(), CliError> {
if !allow_unsafe_inputs {
validate_path(&artifact)?;
ensure_output_parent(&sig_out)?;
}
let digest_alg = parse_digest_alg(&digest_alg)?;
let policy = load_policy(policy_path, allow_missing_policy)?;
if !allow_nonfips
&& !matches!(digest_alg, DigestAlg::Sha256 | DigestAlg::Sha512)
{
return Err(CliError::UnsupportedDigest(
digest_alg.as_str().to_string(),
));
}
let policy = match policy {
Some(p) => p,
None => return Err(CliError::PolicyRequired),
};
let _ = DigestAlg::from_str(&policy.digest_alg).map_err(|_| {
CliError::Other(format!(
"unsupported digest_alg '{}' in policy",
policy.digest_alg
))
})?;
let digest = compute_digest(&artifact, digest_alg)?;
let intent_path = sig_out.with_extension("qsi");
let (_m, _n, allowed) = match &policy.required_signatures {
qs_policy::RequiredSignatures::Quorum { m, n } => {
validate_required_signatures(&policy.required_signatures)
.map_err(|_| CliError::InvalidArgument("invalid required_signatures in policy"))?;
let allowed = dedup_allowed(allowed_kids);
if *m > 1 && allowed.is_empty() {
return Err(CliError::InvalidArgument(
"multi-signature policy requires --allowed-kid entries",
));
}
if !allowed.is_empty() && allowed.len() < *m as usize {
return Err(CliError::InvalidArgument(
"not enough allowed kids to satisfy quorum",
));
}
if !allowed.is_empty() && allowed.len() > *n as usize {
return Err(CliError::InvalidArgument("allowed kid list exceeds quorum"));
}
(*m, *n, allowed)
}
qs_policy::RequiredSignatures::RequiredKids { required } => {
let mut req = required.clone();
req.sort();
req.dedup();
let len = req.len() as u8;
(len, len, req)
}
};
quorum_init(
&intent_path,
&policy,
&policy.default_alg,
&digest,
digest_alg.as_str(),
&allowed,
display_name.as_deref(),
creator.as_deref(),
)?;
println!(
"prepared intent {intent_path} and journal {} (final `.qsig` will be emitted after quorum seal)",
intent_path.with_extension("qsig.part")
);
Ok(())
}
fn handle_trust(action: TrustAction) -> Result<(), CliError> {
match action {
TrustAction::Import { public, trustdb } => {
validate_path(&public)?;
validate_path(&trustdb)?;
let raw = fs::read(public.as_std_path())?;
let der = match qs_crypto::public::spki_der_canonical(&raw) {
Ok(der) => der,
Err(err) => qs_crypto::public_key_to_spki(&raw)
.map_err(|_| CliError::Other(err.to_string()))?,
};
let param =
qs_crypto::spki_mldsa_paramset(&der).map_err(|e| CliError::Other(e.to_string()))?;
if param != "mldsa-87" {
return Err(CliError::Other(format!(
"trust import rejected: {param} is not ML-DSA-87",
)));
}
let kid = qs_crypto::public::kid_from_spki_der(&der);
fs::create_dir_all(trustdb.as_std_path())?;
let out = trustdb.join(format!("{kid}.spki"));
quorum::write_atomic_sync(&out, &der)?;
println!("imported {kid} -> {out}");
Ok(())
}
TrustAction::List { trustdb } => {
if !trustdb.exists() {
return Ok(());
}
validate_path(&trustdb)?;
let mut entries = vec![];
for entry in fs::read_dir(trustdb.as_std_path())? {
let entry = entry?;
if !entry.file_type()?.is_file() {
continue;
}
let path = entry.path();
let Ok(path_utf8) = Utf8PathBuf::from_path_buf(path) else {
continue;
};
let Some(name) = path_utf8.file_name() else {
continue;
};
let (stem, der) = if let Some(stem) = name.strip_suffix(".spki") {
let data = fs::read(path_utf8.as_std_path())?;
let der = qs_crypto::public::spki_der_canonical(&data)
.map_err(|e| CliError::Other(e.to_string()))?;
(stem.to_string(), der)
} else if let Some(stem) = name.strip_suffix(".vk") {
let data = fs::read(path_utf8.as_std_path())?;
let der = qs_crypto::public_key_to_spki(&data)?;
(stem.to_string(), der)
} else {
continue;
};
let param = qs_crypto::spki_mldsa_paramset(&der)
.map_err(|e| CliError::Other(e.to_string()))?;
if param != "mldsa-87" {
return Err(CliError::Other(format!(
"trust entry {path_utf8} is {param}, not ML-DSA-87",
)));
}
let kid = qs_crypto::kid_from_spki_der(&der);
if kid != stem {
return Err(CliError::Other(format!(
"trust entry KID mismatch: {}",
path_utf8
)));
}
entries.push((kid, path_utf8));
}
entries.sort_by(|a, b| a.0.cmp(&b.0));
for (kid, path) in entries {
println!("{kid} {path}");
}
Ok(())
}
}
}
fn handle_quorum(action: QuorumAction) -> Result<(), CliError> {
match action {
QuorumAction::Init {
artifact,
policy,
digest,
intent,
allowed_kid,
display_name,
creator,
} => {
let digest_alg = parse_digest_alg(&digest)?;
let digest_bytes = compute_digest(&artifact, digest_alg)?;
let policy =
qs_policy::load_policy_file(policy.as_std_path()).map_err(CliError::PolicyLoad)?;
let _ = DigestAlg::from_str(&policy.digest_alg).map_err(|_| {
CliError::Other(format!(
"unsupported digest_alg '{}' in policy",
policy.digest_alg
))
})?;
let intent_path = intent.unwrap_or_else(|| artifact.with_extension("qsi"));
let allowed = match &policy.required_signatures {
qs_policy::RequiredSignatures::Quorum { m, n } => {
let allowed = dedup_allowed(allowed_kid);
if *m > 1 && allowed.is_empty() {
return Err(CliError::InvalidArgument(
"multi-signature policy requires --allowed-kid entries",
));
}
if !allowed.is_empty() && allowed.len() < *m as usize {
return Err(CliError::InvalidArgument(
"not enough allowed kids to satisfy quorum",
));
}
if !allowed.is_empty() && allowed.len() > *n as usize {
return Err(CliError::InvalidArgument("allowed kid list exceeds quorum"));
}
allowed
}
qs_policy::RequiredSignatures::RequiredKids { required } => {
let mut req = required.clone();
req.sort();
req.dedup();
req
}
};
quorum_init(
&intent_path,
&policy,
&policy.default_alg,
&digest_bytes,
digest_alg.as_str(),
&allowed,
display_name.as_deref(),
creator.as_deref(),
)?;
println!(
"initialized quorum intent at {intent_path} with journal {}",
intent_path.with_extension("qsig.part")
);
Ok(())
}
QuorumAction::Add { part, fragment } => {
quorum_add(&part, &fragment)?;
println!("added fragment {fragment} to journal {part}");
Ok(())
}
QuorumAction::Cosign {
intent,
secret,
public,
fragment,
append,
} => {
let intent_obj: Intent = load_intent(&intent)?;
if intent_obj.v != 1 {
return Err(CliError::InvalidArgument("unsupported intent version"));
}
validate_path(&secret)?;
validate_path(&public)?;
let pk_bytes = fs::read(public.as_std_path())?;
let derived_kid = qs_crypto::kid_from_public_key(&pk_bytes)?;
if !intent_obj.allowed_kids.is_empty()
&& !intent_obj.allowed_kids.contains(&derived_kid)
{
return Err(CliError::InvalidArgument("kid not permitted by intent"));
}
let sk_bytes = fs::read(secret.as_std_path())?;
let mut drbg = HmacSha512Drbg::from_os(Some(b"qs-cosign"))?;
let digest_alg = DigestAlg::from_str(&intent_obj.digest_alg)
.map_err(|_| CliError::Other("intent digest_alg unsupported".into()))?;
let sig = qs_crypto::sign_mldsa87(
&mut drbg,
&sk_bytes,
intent_obj.digest.as_ref(),
digest_alg,
Some(intent_obj.policy_hash.as_ref()),
)?;
qs_crypto::verify_mldsa87(
&pk_bytes,
intent_obj.digest.as_ref(),
digest_alg,
&sig,
Some(intent_obj.policy_hash.as_ref()),
)?;
let fragment_obj = make_fragment(&intent_obj, &derived_kid, sig);
save_fragment(&fragment, &fragment_obj)?;
println!("wrote fragment {fragment}");
if let Some(part) = append {
validate_path(&part)?;
quorum_add(&part, &fragment)?;
println!("appended fragment into {part}");
}
Ok(())
}
QuorumAction::Seal {
part,
out,
trust_dir,
} => {
let verify = move |kid: &str,
alg: &str,
digest: &[u8],
digest_alg: DigestAlg,
sig: &[u8],
policy: &[u8]|
-> bool {
if alg != "mldsa-87" {
return false;
}
let spki_path = trust_dir.join(format!("{kid}.spki"));
let spki_bytes = match fs::read(spki_path.as_std_path()) {
Ok(bytes) => bytes,
Err(_) => {
let legacy = trust_dir.join(format!("{kid}.vk"));
match fs::read(legacy.as_std_path()) {
Ok(vk_bytes) => match qs_crypto::public_key_to_spki(&vk_bytes) {
Ok(spki) => spki,
Err(_) => return false,
},
Err(_) => return false,
}
}
};
if qs_crypto::kid_from_spki_der(&spki_bytes) != kid {
return false;
}
qs_crypto::verify_mldsa87_spki(&spki_bytes, digest, digest_alg, sig, Some(policy))
.is_ok()
};
quorum_seal(&part, &out, verify)?;
println!("sealed journal {part} into {out}");
Ok(())
}
QuorumAction::Package {
artifact,
policy,
digest,
secret,
public,
out_dir,
zip,
display_name,
creator,
} => {
validate_path(&artifact)?;
let policy =
qs_policy::load_policy_file(policy.as_std_path()).map_err(CliError::PolicyLoad)?;
let digest_alg = parse_digest_alg(&digest)?;
let digest_bytes = compute_digest(&artifact, digest_alg)?;
let (sk_bytes, vk_spki, kid): (Vec<u8>, Vec<u8>, String) =
if let (Some(sk), Some(pk)) = (secret.as_ref(), public.as_ref()) {
let sk_b = fs::read(sk.as_std_path())?;
let pk_b = fs::read(pk.as_std_path())?;
let spki = qs_crypto::public_key_to_spki(&pk_b)?;
let kid = qs_crypto::kid_from_spki_der(&spki);
(sk_b, spki, kid)
} else {
let mut drbg = HmacSha512Drbg::from_os(Some(b"qs-package"))?;
let kp = qs_crypto::keypair_mldsa87(&mut drbg)?;
let spki = qs_crypto::public_key_to_spki(&kp.public)?;
let kid = qs_crypto::kid_from_spki_der(&spki);
(kp.secret.clone(), spki, kid)
};
let dir = tempfile::tempdir().map_err(|e| CliError::Other(format!("tempdir: {e}")))?;
let intent_path = Utf8PathBuf::from_path_buf(dir.path().join("intent.qsi")).unwrap();
let allowed = match &policy.required_signatures {
qs_policy::RequiredSignatures::RequiredKids { required } => {
let mut req = required.clone();
req.sort();
req.dedup();
req
}
_ => vec![kid.clone()],
};
quorum_init(
&intent_path,
&policy,
&policy.default_alg,
&digest_bytes,
digest_alg.as_str(),
&allowed,
display_name.as_deref(),
creator.as_deref(),
)?;
let it: Intent = load_intent(&intent_path)?;
let mut drbg2 = HmacSha512Drbg::from_os(Some(b"qs-package-cs"))
.map_err(|e| CliError::Crypto(e.into()))?;
let da = DigestAlg::from_str(&it.digest_alg)
.map_err(|_| CliError::Other("digest".into()))?;
let sig = qs_crypto::sign_mldsa87(
&mut drbg2,
&sk_bytes,
it.digest.as_ref(),
da,
Some(it.policy_hash.as_ref()),
)?;
let frag = make_fragment(&it, &kid, sig);
let frag_path = Utf8PathBuf::from_path_buf(dir.path().join("frag.csf")).unwrap();
save_fragment(&frag_path, &frag)?;
let part_path = intent_path.with_extension("qsig.part");
quorum_add(&part_path, &frag_path)?;
fs::create_dir_all(out_dir.as_std_path())?;
let sig_out = out_dir
.join(artifact.file_name().unwrap_or("artifact"))
.with_extension("qsig");
let verify = |kidv: &str,
alg: &str,
digest: &[u8],
da2: DigestAlg,
sigb: &[u8],
policy: &[u8]|
-> bool {
if alg != "mldsa-87" {
return false;
}
if kidv != kid {
return false;
}
qs_crypto::verify_mldsa87_spki(&vk_spki, digest, da2, sigb, Some(policy)).is_ok()
};
quorum_seal(&part_path, &sig_out, verify)?;
let img_out = out_dir.join(artifact.file_name().unwrap_or("artifact"));
fs::copy(artifact.as_std_path(), img_out.as_std_path())?;
let pol_out = out_dir.join("policy.json");
let pol_json = serde_json::to_vec_pretty(&policy)
.map_err(|e| CliError::Other(format!("policy serialize: {e}")))?;
fs::write(pol_out.as_std_path(), pol_json)?;
let trust_dir = out_dir.join("trust");
fs::create_dir_all(trust_dir.as_std_path())?;
let spki_out = trust_dir.join(format!("{}.spki", kid));
fs::write(spki_out.as_std_path(), &vk_spki)?;
if zip {
let zip_path = out_dir.with_extension("zip");
let cmd = std::process::Command::new("zip")
.current_dir(out_dir.as_std_path())
.args([
"-r",
"-X",
zip_path.as_std_path().to_str().unwrap_or("../package.zip"),
".",
]) .output();
let _ = cmd;
}
println!("packaged minimal release in {}", out_dir);
Ok(())
}
}
}
fn handle_verify(
inp: Utf8PathBuf,
sig: Utf8PathBuf,
trustdb: Option<Utf8PathBuf>,
json: bool,
) -> Result<(), CliError> {
validate_path(&inp)?;
validate_path(&sig)?;
let trust_dir = trustdb.unwrap_or_else(|| Utf8PathBuf::from("./trust"));
validate_path(&trust_dir)?;
let trust_map = load_trust_store(&trust_dir)?;
let sig_bytes = fs::read(sig.as_std_path())?;
if sig_bytes.len() > 256 * 1024 {
return Err(CliError::Other("qsig too large".into()));
}
let qsig = QSig::decode(&sig_bytes)
.map_err(|e| CliError::Other(format!("qsig decode failed: {e}")))?;
let mut reencoded = Vec::new();
ciborium::ser::into_writer(&qsig, &mut reencoded)
.map_err(|e| CliError::Other(format!("qsig re-encode: {e}")))?;
let canonical_ok = reencoded == sig_bytes;
if !canonical_ok {
return Err(CliError::Other(
"non-canonical .qsig encoding (re-encode mismatch)".into(),
));
}
if qsig.version != 1 {
return Err(CliError::Other("unsupported qsig version".into()));
}
if qsig.alg != "mldsa-87" {
return Err(CliError::Other(format!(
"unsupported algorithm '{}'",
qsig.alg
)));
}
if qsig.domain_sep != "quantum-sign-v1" {
return Err(CliError::Other("unexpected domain separator".into()));
}
let claims = parse_claims(&qsig.signer.claims)?;
claims.validate()?;
if claims.n as usize > MAX_SIGNERS {
return Err(CliError::Other(
"claims.n exceeds maximum supported signers (16)".into(),
));
}
let total_sigs = 1 + qsig.co_sig.len();
if total_sigs > claims.n as usize {
return Err(CliError::Other("too many signatures for claims.n".into()));
}
if total_sigs < claims.m as usize {
return Err(CliError::Other(
"embedded signatures fewer than claims.m".into(),
));
}
let policy_meta = qsig
.meta
.as_ref()
.map(|b| b.as_ref())
.ok_or_else(|| CliError::Other("signature missing policy hash".into()))?;
if policy_meta != claims.policy_hash.as_slice() {
return Err(CliError::Other("policy hash mismatch".into()));
}
let digest_alg = match claims.digest_alg.as_str() {
"sha256" => DigestAlg::Sha256,
"sha512" => DigestAlg::Sha512,
"shake256-64" => DigestAlg::Shake256_64,
other => {
return Err(CliError::Other(format!(
"unsupported digest in claims: {other}"
)))
}
};
let artifact_digest = compute_digest(&inp, digest_alg)?;
if artifact_digest.as_slice() != qsig.digest.as_slice() {
return Err(CliError::Other("message digest mismatch".into()));
}
let mut used = vec![false; claims.kids.len()];
let mut valid = 0usize;
let mut verified_kids = Vec::new();
let mut match_signature = |sig_bytes: &[u8]| -> Result<Option<String>, CliError> {
for (idx, kid) in claims.kids.iter().enumerate() {
if used[idx] {
continue;
}
let spki = trust_map
.get(kid)
.ok_or_else(|| CliError::Other(format!("trust entry not found for kid {kid}")))?;
let param = qs_crypto::spki_mldsa_paramset(spki)
.map_err(|e| CliError::Other(e.to_string()))?;
if param != "mldsa-87" {
continue;
}
if qs_crypto::verify_mldsa87_spki(
spki,
qsig.digest.as_slice(),
digest_alg,
sig_bytes,
Some(claims.policy_hash.as_slice()),
)
.is_ok()
{
used[idx] = true;
return Ok(Some(kid.clone()));
}
}
Ok(None)
};
if let Some(kid) = match_signature(&qsig.sig)? {
valid += 1;
verified_kids.push(kid);
}
for cs in &qsig.co_sig {
if let Some(kid) = match_signature(cs.as_ref())? {
valid += 1;
verified_kids.push(kid);
}
}
if valid < claims.m as usize {
return Err(CliError::Other(format!(
"verified {valid} signatures but policy requires {}",
claims.m
)));
}
if json {
verified_kids.sort();
let report = VerifyReport {
status: "ok",
alg: &qsig.alg,
digest_alg: claims.digest_alg.clone(),
domain: qsig.domain_sep.clone(),
canonical: canonical_ok,
m: claims.m,
n: claims.n,
kids_total: claims.kids.len(),
kids_verified: verified_kids.len(),
verified_kids,
policy_hash_hex: to_hex(&claims.policy_hash),
file_digest_hex: to_hex(&artifact_digest),
qsig_size: sig_bytes.len(),
message: None,
creator: claims.creator.clone(),
display_name: claims.display_name.clone(),
};
let json_out = serde_json::to_string_pretty(&report)
.map_err(|e| CliError::Other(format!("json encode failed: {e}")))?;
println!("{json_out}");
} else {
println!(
"OK: verified {}/{} unique signatures (Level-5, {})",
verified_kids.len(),
claims.n,
claims.digest_alg
);
}
Ok(())
}
struct ClaimsTuple {
policy_hash: Vec<u8>,
kids: Vec<String>,
m: u64,
n: u64,
digest_alg: String,
creator: Option<String>,
display_name: Option<String>,
tsa_nonce: Option<Vec<u8>>,
}
impl ClaimsTuple {
fn validate(&self) -> Result<(), CliError> {
if self.m == 0 || self.n == 0 || self.m > self.n {
return Err(CliError::Other("invalid m-of-n in claims".into()));
}
if self.kids.len() < self.m as usize {
return Err(CliError::Other("claims.kids shorter than claims.m".into()));
}
if self.kids.len() > self.n as usize {
return Err(CliError::Other("claims.kids exceeds claims.n".into()));
}
if self.kids.windows(2).any(|w| w[0].as_str() >= w[1].as_str()) {
return Err(CliError::Other(
"claims.kids must be strictly sorted and unique".into(),
));
}
Ok(())
}
}
#[derive(Serialize)]
struct VerifyReport<'a> {
status: &'a str,
alg: &'a str,
digest_alg: String,
domain: String,
canonical: bool,
m: u64,
n: u64,
kids_total: usize,
kids_verified: usize,
verified_kids: Vec<String>,
policy_hash_hex: String,
file_digest_hex: String,
qsig_size: usize,
message: Option<String>,
creator: Option<String>,
display_name: Option<String>,
}
type ClaimsV2 = (serde_bytes::ByteBuf, Vec<String>, u64, u64, String, String, String);
type ClaimsV1 = (serde_bytes::ByteBuf, Vec<String>, u64, u64, String);
type ClaimsV3 = (serde_bytes::ByteBuf, Vec<String>, u64, u64, String, String, String, Option<serde_bytes::ByteBuf>);
fn parse_claims(bytes: &[u8]) -> Result<ClaimsTuple, CliError> {
use std::io::Cursor;
let try8: Result<ClaimsV3, _> = ciborium::de::from_reader(Cursor::new(bytes));
if let Ok(raw8) = try8 {
return Ok(ClaimsTuple {
policy_hash: raw8.0.into_vec(),
kids: raw8.1,
m: raw8.2,
n: raw8.3,
digest_alg: raw8.4,
display_name: (!raw8.5.is_empty()).then_some(raw8.5),
creator: (!raw8.6.is_empty()).then_some(raw8.6),
tsa_nonce: raw8.7.map(|b| b.into_vec()),
});
}
let try7: Result<ClaimsV2, _> =
ciborium::de::from_reader(Cursor::new(bytes));
if let Ok(raw7) = try7 {
return Ok(ClaimsTuple {
policy_hash: raw7.0.into_vec(),
kids: raw7.1,
m: raw7.2,
n: raw7.3,
digest_alg: raw7.4,
display_name: (!raw7.5.is_empty()).then_some(raw7.5),
creator: (!raw7.6.is_empty()).then_some(raw7.6),
tsa_nonce: None,
});
}
let raw5: ClaimsV1 =
ciborium::de::from_reader(Cursor::new(bytes))
.map_err(|e| CliError::Other(format!("claims decode failed: {e}")))?;
Ok(ClaimsTuple {
policy_hash: raw5.0.into_vec(),
kids: raw5.1,
m: raw5.2,
n: raw5.3,
digest_alg: raw5.4,
display_name: None,
creator: None,
tsa_nonce: None,
})
}
fn to_hex(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
let _ = write!(&mut out, "{:02x}", byte);
}
out
}
fn load_trust_store(dir: &Utf8Path) -> Result<BTreeMap<String, Vec<u8>>, CliError> {
let mut map = BTreeMap::new();
if !dir.exists() {
return Ok(map);
}
for entry in fs::read_dir(dir.as_std_path())? {
let entry = entry?;
if !entry.file_type()?.is_file() {
continue;
}
let path = entry.path();
let Ok(path_utf8) = Utf8PathBuf::from_path_buf(path) else {
continue;
};
let Some(name) = path_utf8.file_name() else {
continue;
};
let (stem, der) = if let Some(stem) = name.strip_suffix(".spki") {
let data = fs::read(path_utf8.as_std_path())?;
let der = qs_crypto::public::spki_der_canonical(&data)
.map_err(|e| CliError::Other(e.to_string()))?;
(stem.to_string(), der)
} else if let Some(stem) = name.strip_suffix(".vk") {
let data = fs::read(path_utf8.as_std_path())?;
let der = qs_crypto::public_key_to_spki(&data)?;
(stem.to_string(), der)
} else {
continue;
};
let kid = qs_crypto::kid_from_spki_der(&der);
if kid != stem {
eprintln!(
"warning: skipping trust entry with KID mismatch: {} (expected stem {}, got {})",
path_utf8, stem, kid
);
continue;
}
map.entry(kid).or_insert(der);
}
Ok(map)
}
fn parse_digest_alg(flag: &str) -> Result<DigestAlg, CliError> {
DigestAlg::from_str(flag).map_err(|_| CliError::UnsupportedDigest(flag.to_string()))
}
fn compute_digest(path: &Utf8Path, alg: DigestAlg) -> Result<Vec<u8>, CliError> {
use std::fs::File;
use std::io::{BufReader, Read};
let file = File::open(path.as_std_path())?;
let mut reader = BufReader::new(file);
let mut buf = [0u8; 64 * 1024];
let digest = match alg {
DigestAlg::Sha256 => {
let mut hasher = Sha256::new();
loop {
let n = reader.read(&mut buf)?;
if n == 0 {
break;
}
Digest::update(&mut hasher, &buf[..n]);
}
hasher.finalize().to_vec()
}
DigestAlg::Sha512 => {
let mut hasher = Sha512::new();
loop {
let n = reader.read(&mut buf)?;
if n == 0 {
break;
}
Digest::update(&mut hasher, &buf[..n]);
}
hasher.finalize().to_vec()
}
DigestAlg::Shake256_64 => {
let mut hasher = Shake256::default();
loop {
let n = reader.read(&mut buf)?;
if n == 0 {
break;
}
sha3::digest::Update::update(&mut hasher, &buf[..n]);
}
let mut reader_xof = hasher.finalize_xof();
let mut out = vec![0u8; alg.output_len()];
XofReader::read(&mut reader_xof, &mut out);
out
}
};
Ok(digest)
}
fn dedup_allowed(mut allowed: Vec<String>) -> Vec<String> {
allowed.sort();
allowed.dedup();
allowed
}
fn load_policy(path: Option<Utf8PathBuf>, allow_missing: bool) -> Result<Option<Policy>, CliError> {
match (path, allow_missing) {
(Some(path), _) => Ok(Some(
qs_policy::load_policy_file(path.as_std_path()).map_err(CliError::PolicyLoad)?,
)),
(None, true) => Ok(None),
(None, false) => Err(CliError::PolicyRequired),
}
}
mod io_safe {
use super::CliError;
use camino::{Utf8Component, Utf8Path};
use std::fs::File;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
pub fn validate_path(path: &Utf8Path) -> Result<(), CliError> {
if path
.components()
.any(|c| matches!(c, Utf8Component::ParentDir))
{
return Err(CliError::UnsafePath(path.to_path_buf()));
}
Ok(())
}
pub fn create_exclusive(path: &Utf8Path) -> Result<File, CliError> {
#[cfg(unix)]
{
let mut opts = std::fs::OpenOptions::new();
opts.write(true)
.create_new(true)
.mode(0o600)
.custom_flags(libc::O_NOFOLLOW);
opts.open(path).map_err(CliError::from)
}
#[cfg(not(unix))]
{
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.map_err(CliError::from)
}
}
pub fn ensure_output_parent(path: &Utf8Path) -> Result<(), CliError> {
if let Some(parent) = path.parent() {
validate_path(parent)?;
if !parent.exists() {
std::fs::create_dir_all(parent.as_std_path())?;
}
}
Ok(())
}
}
use io_safe::{create_exclusive, ensure_output_parent, validate_path};
#[derive(Debug)]
enum CliError {
PolicyRequired,
PolicyLoad(qs_policy::Error),
PolicyValidation(ValidationError),
UnsupportedDigest(String),
InvalidArgument(&'static str),
UnsafePath(Utf8PathBuf),
Crypto(qs_crypto::CryptoError),
Io(io::Error),
Other(String),
}
impl fmt::Display for CliError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CliError::PolicyRequired => write!(
f,
"policy file required (use --policy or --allow-missing-policy)"
),
CliError::PolicyLoad(err) => write!(f, "policy load failed: {err:?}"),
CliError::PolicyValidation(err) => write!(f, "policy validation failed: {err:?}"),
CliError::UnsupportedDigest(d) => write!(f, "unsupported digest algorithm: {d}"),
CliError::InvalidArgument(msg) => write!(f, "invalid argument: {msg}"),
CliError::UnsafePath(path) => write!(f, "unsafe path rejected: {path}"),
CliError::Crypto(err) => write!(f, "crypto error: {err}"),
CliError::Io(err) => write!(f, "I/O error: {err}"),
CliError::Other(msg) => write!(f, "{msg}"),
}
}
}
impl StdError for CliError {}
impl From<qs_policy::Error> for CliError {
fn from(err: qs_policy::Error) -> Self {
CliError::PolicyLoad(err)
}
}
impl From<ValidationError> for CliError {
fn from(err: ValidationError) -> Self {
CliError::PolicyValidation(err)
}
}
impl From<qs_crypto::CryptoError> for CliError {
fn from(err: qs_crypto::CryptoError) -> Self {
CliError::Crypto(err)
}
}
impl From<qs_crypto::DrbgError> for CliError {
fn from(err: qs_crypto::DrbgError) -> Self {
CliError::Crypto(qs_crypto::CryptoError::from(err))
}
}
impl From<io::Error> for CliError {
fn from(err: io::Error) -> Self {
CliError::Io(err)
}
}
#[derive(Subcommand, Debug)]
enum TimestampAction {
Add(TimestampAddArgs),
Verify(TimestampVerifyArgs),
}
#[derive(Args, Debug)]
struct TimestampAddArgs {
#[arg(long)]
sig: Utf8PathBuf,
#[arg(long)]
tsa: String,
#[arg(long)]
policy_oid: Option<String>,
}
#[derive(Args, Debug)]
struct TimestampVerifyArgs {
#[arg(long)]
sig: Utf8PathBuf,
#[arg(long, default_value = "./tsa-roots")]
tsa_trust: Utf8PathBuf,
#[arg(long, default_value_t = false)]
json: bool,
}
fn handle_timestamp(action: TimestampAction) -> Result<(), CliError> {
match action {
TimestampAction::Add(a) => cmd_timestamp_add(a),
TimestampAction::Verify(a) => cmd_timestamp_verify(a),
}
}
fn cmd_timestamp_add(a: TimestampAddArgs) -> Result<(), CliError> {
validate_path(&a.sig)?;
let (mut qsig, _raw) = load_qsig(&a.sig)?;
if qsig.tst.is_some() {
return Err(CliError::Other("timestamp already attached".into()));
}
let claims = parse_claims(&qsig.signer.claims)?;
let dalg = DigestAlg::from_str(&claims.digest_alg)
.map_err(|_| CliError::Other("unsupported digest in claims".into()))?;
let mut nonce = [0u8; 16];
let rnd = qs_crypto::random_bytes(16)?;
nonce.copy_from_slice(&rnd);
let req = quantum_sign::tsp::TsaRequest {
imprint_alg: dalg.as_str(),
imprint: qsig.digest.clone(),
policy_oid: a.policy_oid.clone(),
nonce,
cert_req: true,
};
let rsp = quantum_sign::tsp::request_timestamp(&a.tsa, &req)
.map_err(|e| CliError::Other(format!("tsa: {e}")))?;
if rsp.der.len() > 64 * 1024 {
return Err(CliError::Other("timestamp token too large".into()));
}
qsig.tst = Some(serde_bytes::ByteBuf::from(rsp.der));
write_canonical_qsig(&a.sig, &qsig)?;
println!("attached timestamp (gen_time={} kid={})", rsp.gen_time_unix, rsp.tsa_kid);
Ok(())
}
fn cmd_timestamp_verify(a: TimestampVerifyArgs) -> Result<(), CliError> {
validate_path(&a.sig)?;
let (qsig, _raw) = load_qsig(&a.sig)?;
let tst = qsig
.tst
.as_ref()
.ok_or_else(|| CliError::Other("no timestamp present".into()))?;
let claims = parse_claims(&qsig.signer.claims)?;
let dalg = DigestAlg::from_str(&claims.digest_alg)
.map_err(|_| CliError::Other("unsupported digest in claims".into()))?;
let trust = load_all_der(&a.tsa_trust)?;
let resp = quantum_sign::tsp::verify_token(
tst.as_ref(),
dalg.as_str(),
&qsig.digest,
claims.tsa_nonce.as_deref(),
&trust,
)
.map_err(|e| CliError::Other(format!("tsa verify: {e}")))?;
if a.json {
println!(
"{}",
serde_json::json!({
"status": "ok",
"gen_time": resp.gen_time_unix,
"serial": resp.serial_hex,
"tsa_kid": resp.tsa_kid
})
);
} else {
println!(
"OK: gen_time={} serial={} kid={}",
resp.gen_time_unix, resp.serial_hex, resp.tsa_kid
);
}
Ok(())
}
fn load_all_der(dir: &Utf8Path) -> Result<Vec<Vec<u8>>, CliError> {
let mut out = Vec::new();
if !dir.exists() {
return Ok(out);
}
for entry in std::fs::read_dir(dir.as_std_path())? {
let entry = entry?;
if !entry.file_type()?.is_file() {
continue;
}
let path = entry.path();
let Ok(p) = Utf8PathBuf::from_path_buf(path) else { continue; };
out.push(std::fs::read(p.as_std_path())?);
}
Ok(out)
}
#[derive(Subcommand, Debug)]
enum LogAction {
Append(LogAppendArgs),
Verify(LogVerifyArgs),
Check(LogCheckArgs),
}
#[derive(Args, Debug)]
struct LogAppendArgs {
#[arg(long)]
sig: Utf8PathBuf,
#[arg(long, value_name = "URL")]
log: String,
#[arg(long, value_name = "FILE")]
log_trust: Utf8PathBuf,
}
#[derive(Args, Debug)]
struct LogVerifyArgs {
#[arg(long)]
sig: Utf8PathBuf,
#[arg(long)]
log_trust: Utf8PathBuf,
#[arg(long, default_value_t = false)]
json: bool,
}
#[derive(Args, Debug)]
struct LogCheckArgs {
#[arg(long, value_name = "FILE")]
from: Utf8PathBuf,
#[arg(long, value_name = "FILE")]
to: Utf8PathBuf,
#[arg(long, value_name = "FILE")]
proof: Utf8PathBuf,
#[arg(long, value_name = "FILE")]
log_trust: Utf8PathBuf,
#[arg(long, default_value_t = false)]
json: bool,
}
fn handle_log(action: LogAction) -> Result<(), CliError> {
match action {
LogAction::Append(a) => cmd_log_append(a),
LogAction::Verify(a) => cmd_log_verify(a),
LogAction::Check(a) => cmd_log_check(a),
}
}
fn cmd_log_append(a: LogAppendArgs) -> Result<(), CliError> {
validate_path(&a.sig)?;
validate_path(&a.log_trust)?;
let (mut qsig, _raw) = load_qsig(&a.sig)?;
if qsig.tlog.is_some() {
return Err(CliError::Other("transparency receipt already stapled".into()));
}
let leaf = qsig
.core_leaf_hash_sha256()
.map_err(|e| CliError::Other(format!("{e}")))?;
let receipt = quantum_sign::transparency::append(&a.log, leaf)
.map_err(|e| CliError::Other(format!("append: {e}")))?;
let spki = std::fs::read(a.log_trust.as_std_path())?;
quantum_sign::transparency::verify_inclusion(&receipt, leaf, &spki)
.map_err(|e| CliError::Other(format!("verify: {e}")))?;
let mut buf = Vec::new();
ciborium::ser::into_writer(&receipt, &mut buf)
.map_err(|e| CliError::Other(format!("cbor: {e}")))?;
if buf.len() > 32 * 1024 {
return Err(CliError::Other("transparency receipt too large".into()));
}
qsig.tlog = Some(quantum_sign::format::Transparency {
root: receipt.sth.root_hash.clone(),
proof: buf,
});
write_canonical_qsig(&a.sig, &qsig)?;
println!(
"appended: index={} size={} log_id={}",
receipt.index, receipt.sth.tree_size, receipt.sth.log_id
);
Ok(())
}
fn cmd_log_check(a: LogCheckArgs) -> Result<(), CliError> {
validate_path(&a.from)?;
validate_path(&a.to)?;
validate_path(&a.proof)?;
validate_path(&a.log_trust)?;
let sth_old: quantum_sign::transparency::Sth = {
let bytes = std::fs::read(a.from.as_std_path())?;
ciborium::de::from_reader(bytes.as_slice()).map_err(|e| CliError::Other(format!("from STH decode: {e}")))?
};
let sth_new: quantum_sign::transparency::Sth = {
let bytes = std::fs::read(a.to.as_std_path())?;
ciborium::de::from_reader(bytes.as_slice()).map_err(|e| CliError::Other(format!("to STH decode: {e}")))?
};
let proof_nodes: Vec<Vec<u8>> = {
let bytes = std::fs::read(a.proof.as_std_path())?;
ciborium::de::from_reader(bytes.as_slice()).map_err(|e| CliError::Other(format!("proof decode: {e}")))?
};
let max_nodes = (16 * 1024) / 32;
let mut proof = Vec::<[u8; 32]>::new();
for node in proof_nodes {
if node.len() != 32 {
return Err(CliError::Other("invalid proof node length".into()));
}
if proof.len() >= max_nodes {
return Err(CliError::Other("consistency proof too large".into()));
}
let mut b = [0u8; 32];
b.copy_from_slice(&node);
proof.push(b);
}
let spki = std::fs::read(a.log_trust.as_std_path())?;
let digest_old = quantum_sign::transparency::sha512(&quantum_sign::transparency::encode_sth_sig_input(&sth_old).map_err(CliError::Other)?);
let digest_new = quantum_sign::transparency::sha512(&quantum_sign::transparency::encode_sth_sig_input(&sth_new).map_err(CliError::Other)?);
quantum_sign::crypto::verify_mldsa87_spki(&spki, &digest_old, quantum_sign::crypto::DigestAlg::Sha512, &sth_old.sig, None)?;
quantum_sign::crypto::verify_mldsa87_spki(&spki, &digest_new, quantum_sign::crypto::DigestAlg::Sha512, &sth_new.sig, None)?;
quantum_sign::transparency::verify_consistency(&sth_old, &sth_new, &proof)
.map_err(|e| CliError::Other(format!("consistency: {e}")))?;
if a.json {
println!(
"{}",
serde_json::json!({
"status": "ok",
"old_tree_size": sth_old.tree_size,
"new_tree_size": sth_new.tree_size,
"old_ts": sth_old.timestamp,
"new_ts": sth_new.timestamp
})
);
} else {
println!(
"OK: consistency verified ({} -> {})",
sth_old.tree_size, sth_new.tree_size
);
}
Ok(())
}
fn cmd_log_verify(a: LogVerifyArgs) -> Result<(), CliError> {
validate_path(&a.sig)?;
validate_path(&a.log_trust)?;
let (qsig, _raw) = load_qsig(&a.sig)?;
let tlog = qsig
.tlog
.as_ref()
.ok_or_else(|| CliError::Other("no transparency receipt present".into()))?;
let receipt: quantum_sign::transparency::AppendReceipt = ciborium::de::from_reader(tlog.proof.as_slice())
.map_err(|e| CliError::Other(format!("tlog decode: {e}")))?;
let leaf = qsig
.core_leaf_hash_sha256()
.map_err(|e| CliError::Other(format!("{e}")))?;
let spki = std::fs::read(a.log_trust.as_std_path())?;
quantum_sign::transparency::verify_inclusion(&receipt, leaf, &spki)
.map_err(|e| CliError::Other(format!("verify: {e}")))?;
if a.json {
println!(
"{}",
serde_json::json!({
"status": "ok",
"log_id": receipt.sth.log_id,
"tree_size": receipt.sth.tree_size,
"index": receipt.index,
"timestamp": receipt.sth.timestamp
})
);
} else {
println!(
"OK: log_id={} tree_size={} index={} ts={}",
receipt.sth.log_id, receipt.sth.tree_size, receipt.index, receipt.sth.timestamp
);
}
Ok(())
}
fn load_qsig(path: &Utf8Path) -> Result<(QSig, Vec<u8>), CliError> {
let raw = std::fs::read(path.as_std_path())?;
let qsig = QSig::decode(&raw).map_err(|e| CliError::Other(format!("qsig decode: {e}")))?;
Ok((qsig, raw))
}
fn write_canonical_qsig(path: &Utf8Path, qsig: &QSig) -> Result<(), CliError> {
let encoded = qsig
.encode_to_vec()
.map_err(|e| CliError::Other(format!("qsig encode: {e}")))?;
let rt = QSig::decode(&encoded).map_err(|e| CliError::Other(format!("qsig decode: {e}")))?;
let re = rt
.encode_to_vec()
.map_err(|e| CliError::Other(format!("qsig encode: {e}")))?;
if encoded != re {
return Err(CliError::Other("non-canonical .qsig (re-encode mismatch)".into()));
}
std::fs::write(path.as_std_path(), &encoded)?;
Ok(())
}