use anyhow::{bail, Result};
use clap::Args;
use clap_complete::engine::{ArgValueCompleter, PathCompleter};
use std::io::Write as _;
use std::path::PathBuf;
use yb_core::orchestrator;
use yb_core::{store::Store, Context, SecretOp};
use crate::cli::util::{
check_blob_signature, resolve_patterns, ExitStatus, SigVerdict, StatusError,
};
use crate::complete::complete_blob_names;
#[derive(Args, Debug)]
pub struct FetchArgs {
#[arg(required = true, add = ArgValueCompleter::new(complete_blob_names))]
pub patterns: Vec<String>,
#[arg(short = 'p', long = "stdout")]
pub stdout: bool,
#[arg(short = 'o', long = "output", add = ArgValueCompleter::new(PathCompleter::file()))]
pub output: Option<PathBuf>,
#[arg(short = 'O', long = "output-dir", add = ArgValueCompleter::new(PathCompleter::dir()))]
pub output_dir: Option<PathBuf>,
#[arg(long = "no-check")]
pub no_check: bool,
#[arg(short = 'x', long = "extract", hide = true)]
pub extract: bool,
}
pub fn run(ctx: &Context, args: &FetchArgs) -> Result<()> {
let store = Store::from_device(&ctx.reader, ctx.piv.as_ref())?;
if !store.kind.is_post_quantum() {
ctx.enforce_default_policy(SecretOp::Fetch)?;
}
if args.stdout && args.output.is_some() {
bail!("--stdout and --output are mutually exclusive");
}
if args.stdout && args.output_dir.is_some() {
bail!("--stdout and --output-dir are mutually exclusive");
}
if args.output.is_some() && args.output_dir.is_some() {
bail!("--output and --output-dir are mutually exclusive");
}
if args.extract {
eprintln!("Warning: --extract is deprecated; saving to files is now the default behavior");
}
let all_blobs = orchestrator::list_blobs(&store);
let all_blob_names: Vec<String> = all_blobs.iter().map(|b| b.name.clone()).collect();
let matched = resolve_patterns(&args.patterns, &all_blob_names, false)?;
if (args.stdout || args.output.is_some()) && matched.len() != 1 {
bail!(
"--stdout / --output require exactly one matching blob, but {} matched",
matched.len()
);
}
if args.no_check {
refuse_encrypted(&store, &matched)?;
if store.kind.is_post_quantum() {
return fetch_checksum_only(ctx, args, &store, &matched);
}
}
if store.kind.is_post_quantum() {
return fetch_post_quantum(ctx, args, &store, &matched);
}
let verifying_key = ctx
.piv
.read_certificate(&ctx.reader, store.store_key_slot)
.ok()
.and_then(|cert_der| yb_core::parse_ec_public_key_from_cert_der(&cert_der).ok())
.map(|pk| p256::ecdsa::VerifyingKey::from(&pk));
for name in &matched {
if let Some(head) = store.find_head(name) {
match check_blob_signature(head, &store, verifying_key.as_ref()) {
SigVerdict::Verified | SigVerdict::ChecksumOk => {}
SigVerdict::Corrupted => bail!(
"blob '{name}' is corrupted or was tampered with: its signature does \
not verify; nothing was fetched"
),
SigVerdict::Unsigned => eprintln!(
"Warning: blob '{name}' has no signature (stored by an older yb); \
its integrity cannot be checked"
),
SigVerdict::Unverifiable => eprintln!(
"Warning: blob '{name}' could not be checked: no certificate in slot \
0x{:02x}",
store.store_key_slot
),
SigVerdict::NewerVersion => eprintln!(
"Warning: blob '{name}' was signed by a newer version of yb, so its \
integrity cannot be checked here; update yb to check it"
),
}
}
}
for name in &matched {
if store
.find_head(name)
.is_some_and(orchestrator::uses_legacy_encryption)
{
eprintln!(
"Warning: blob '{name}' uses the legacy encryption of older yb versions \
(AES-CBC, unauthenticated); store it again to protect it with AES-GCM"
);
}
let encrypted = store.find_head(name).is_some_and(|h| h.is_encrypted());
let pin = if encrypted { ctx.require_pin()? } else { None };
let data = orchestrator::fetch_blob(
&store,
ctx.piv.as_ref(),
&ctx.reader,
name,
pin.as_deref(),
ctx.debug,
)?
.ok_or_else(|| {
StatusError::new(ExitStatus::NotFound, format!("blob '{name}' not found"))
})?;
write_output(ctx, args, name, &data)?;
}
Ok(())
}
#[cfg(not(windows))]
fn fetch_post_quantum(
ctx: &Context,
args: &FetchArgs,
store: &Store,
matched: &[String],
) -> Result<()> {
use yb_core::orchestrator::post_quantum::{quick_check, IntegrityError};
for name in matched {
let head = store.find_head(name).ok_or_else(|| {
StatusError::new(ExitStatus::NotFound, format!("blob '{name}' not found"))
})?;
if quick_check(head, store) == SigVerdict::Corrupted {
return Err(anyhow::Error::from(IntegrityError::Corrupted(name.clone()))
.context("nothing was fetched"));
}
}
let all_unencrypted = matched
.iter()
.all(|n| store.find_head(n).is_some_and(|h| !h.is_encrypted()));
let session = ctx
.post_quantum_session()
.map_err(|e| suggest_no_check(e, all_unencrypted, None))?;
let descriptor = store.descriptor().expect("a post-quantum store");
let protection = session.protection(ctx, descriptor);
let mut fetched = Vec::with_capacity(matched.len());
for name in matched {
let data = orchestrator::fetch_blob_with(store, &protection, name)
.map_err(|e| {
let retries = session.device.pin_retries().ok();
suggest_no_check(e.context("nothing was fetched"), all_unencrypted, retries)
})?
.ok_or_else(|| {
StatusError::new(ExitStatus::NotFound, format!("blob '{name}' not found"))
})?;
fetched.push((name, data));
}
for (name, data) in fetched {
write_output(ctx, args, name, &data)?;
}
Ok(())
}
#[cfg(windows)]
fn fetch_post_quantum(_: &Context, _: &FetchArgs, _: &Store, _: &[String]) -> Result<()> {
bail!("post-quantum stores are not supported on Windows")
}
fn refuse_encrypted(store: &Store, matched: &[String]) -> Result<()> {
if let Some(name) = matched
.iter()
.find(|n| store.find_head(n).is_some_and(|h| h.is_encrypted()))
{
return Err(
anyhow::Error::from(orchestrator::NoPinFetchError::Encrypted(name.clone()))
.context("--no-check reads unencrypted blobs only; nothing was fetched"),
);
}
Ok(())
}
fn fetch_checksum_only(
ctx: &Context,
args: &FetchArgs,
store: &Store,
matched: &[String],
) -> Result<()> {
let mut fetched = Vec::with_capacity(matched.len());
for name in matched {
let (data, _) = orchestrator::fetch_unencrypted(store, ctx.piv.as_ref(), &ctx.reader, name)
.map_err(|e| e.context("nothing was fetched"))?
.ok_or_else(|| {
StatusError::new(ExitStatus::NotFound, format!("blob '{name}' not found"))
})?;
fetched.push((name, data));
}
let names: Vec<String> = matched.iter().map(|n| format!("'{n}'")).collect();
eprintln!(
"warning: checksum only: {} checked against corruption, not against forgery (the \
seal and the YubiKey's management key were not checked). Anyone holding the \
management key could have written {}.",
names.join(", "),
if names.len() == 1 { "it" } else { "them" }
);
for (name, data) in fetched {
write_output(ctx, args, name, &data)?;
}
Ok(())
}
#[cfg(not(windows))]
fn suggest_no_check(
err: anyhow::Error,
all_unencrypted: bool,
retries: Option<u32>,
) -> anyhow::Error {
use yb_core::fido2::Fido2Error;
let locked = err.chain().any(|e| {
matches!(
e.downcast_ref::<Fido2Error>(),
Some(
Fido2Error::PinBlocked
| Fido2Error::PinAuthBlocked
| Fido2Error::FewAttempts(_)
| Fido2Error::NoCredential
| Fido2Error::PinNotSet
| Fido2Error::NoDevice
)
) || (matches!(e.downcast_ref::<Fido2Error>(), Some(Fido2Error::PinInvalid))
&& retries.is_some_and(|n| n <= 3))
});
if !(locked && all_unencrypted) {
return err;
}
yb_core::errors::YbError::new(format!("{err:#}"))
.fix(
"the blobs asked for are unencrypted: `yb fetch --no-check …` reads them without \
the FIDO2 PIN, checking their checksum only (not their seal)",
)
.into()
}
fn write_output(ctx: &Context, args: &FetchArgs, name: &str, data: &[u8]) -> Result<()> {
let dest = if args.stdout {
std::io::stdout().write_all(data)?;
return Ok(());
} else if let Some(ref path) = args.output {
path.clone()
} else if let Some(ref dir) = args.output_dir {
dir.join(name)
} else {
PathBuf::from(name)
};
std::fs::write(&dest, data)?;
if !ctx.quiet {
eprintln!(
"Fetched '{}' → {} ({} bytes)",
name,
dest.display(),
data.len()
);
}
Ok(())
}