use ssign_core::{auth, authenticode, card, client, otp, sign, timestamp};
use anyhow::{bail, Context, Result};
use clap::Parser;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Parser, Debug)]
#[command(name = "ssign", version, about, long_about = None)]
#[command(after_long_help = USAGE_NOTES)]
struct Cli {
#[arg(value_name = "FILES", required = true)]
files: Vec<PathBuf>,
#[arg(short = 'e', long, env = "CERTUM_EMAIL", value_name = "EMAIL")]
email: String,
#[arg(short = 'O', long, env = "CERTUM_OTP", value_name = "SEED")]
otp: Option<String>,
#[arg(short = 'T', long, env = "CERTUM_TOKEN", value_name = "CODE")]
token: Option<String>,
#[arg(short = 'o', long, value_name = "DIR")]
output_dir: Option<PathBuf>,
#[arg(long, value_name = "URL", default_value = "http://time.certum.pl/")]
timestamp_url: String,
#[arg(short = 'n', long, value_name = "TEXT")]
name: Option<String>,
#[arg(short = 'u', long, value_name = "URL")]
url: Option<String>,
#[arg(long)]
backup: bool,
#[arg(short, long)]
verbose: bool,
}
enum Otp {
Seed(String),
Code(String),
}
const USAGE_NOTES: &str = "\
AUTHENTICATION (exactly one of --otp / --token):
--otp <SEED> your TOTP seed; ssign computes the 6-digit code. Best for CI:
set it once as the CERTUM_OTP secret and every run is hands-off.
--token <CODE> a code you read from your authenticator right now. Best for a
manual, local sign when you'd rather not store the seed.
Every flag also reads an environment variable (CERTUM_EMAIL / CERTUM_OTP /
CERTUM_TOKEN). Prefer the env vars for secrets — a value passed on the command
line is visible in your shell history and in the process list.
EXAMPLES
# manual, on your own machine (paste the current code):
ssign -e you@example.com -T 123456 app.exe
# CI / automation (seed once, then unattended):
export CERTUM_EMAIL=you@example.com CERTUM_OTP=BASE32SEED
ssign app.exe installer.msi driver.sys
";
fn main() -> Result<()> {
let cli = Cli::parse();
let otp = match (&cli.otp, &cli.token) {
(Some(_), Some(_)) => bail!("pass only one of --otp (seed) or --token (code)"),
(None, None) => bail!("authentication required: pass --otp <seed> or --token <code> (or set CERTUM_OTP / CERTUM_TOKEN)"),
(Some(seed), None) => Otp::Seed(seed.clone()),
(None, Some(code)) => Otp::Code(code.clone()),
};
run(&cli, otp).context("signing failed")
}
fn resolve_code(otp: &Otp) -> Result<String> {
match otp {
Otp::Code(code) => Ok(code.clone()),
Otp::Seed(seed) => {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.context("system clock before 1970")?
.as_secs();
Ok(otp::Totp::parse(seed)
.context("invalid TOTP seed / otpauth URI in --otp / CERTUM_OTP")?
.code_at(now))
}
}
}
fn run(cli: &Cli, otp: Otp) -> Result<()> {
let code = resolve_code(&otp)?;
if cli.verbose {
eprintln!("· logging in as {}…", cli.email);
}
let token = auth::login(&cli.email, &code).context("login")?.0;
let http = client::client()?;
let card = card::fetch(&http, &token).context("fetching card/certificate")?;
if cli.verbose {
eprintln!("· card {} ready, certificate fetched", card.serial);
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.context("system clock before 1970")?
.as_secs();
let signing_time = authenticode::utc_time(now);
for file in &cli.files {
if cli.verbose {
eprintln!("· signing {}…", file.display());
}
let pe = std::fs::read(file).with_context(|| format!("reading {}", file.display()))?;
let prep =
authenticode::prepare(&pe, cli.name.as_deref(), cli.url.as_deref(), &signing_time)
.with_context(|| format!("preparing {}", file.display()))?;
let signature = sign::request(&http, &token, &card, &prep.to_be_signed)
.with_context(|| format!("remote signing {}", file.display()))?;
let cert_der = authenticode::pem_to_der(&card.certificate_pem)?;
let ts = if cli.timestamp_url.is_empty() {
None
} else {
Some(
timestamp::fetch(&cli.timestamp_url, &signature)
.with_context(|| format!("timestamping {}", file.display()))?,
)
};
let signed = authenticode::finalize(prep, &signature, &cert_der, ts.as_deref())
.with_context(|| format!("assembling signature for {}", file.display()))?;
let out = output_path(file, cli.output_dir.as_deref())?;
if cli.backup && out == *file {
std::fs::write(file.with_extension("orig"), &pe).context("writing backup")?;
}
std::fs::write(&out, &signed).with_context(|| format!("writing {}", out.display()))?;
println!("signed {}", out.display());
}
Ok(())
}
fn output_path(file: &std::path::Path, output_dir: Option<&std::path::Path>) -> Result<PathBuf> {
match output_dir {
None => Ok(file.to_path_buf()),
Some(dir) => {
std::fs::create_dir_all(dir).context("creating output dir")?;
let name = file.file_name().context("input has no file name")?;
Ok(dir.join(name))
}
}
}