use clap::{ArgAction, Args, Parser, Subcommand, ValueEnum};
use std::io::{IsTerminal, Read, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use kc::crypto::KeyBlob;
use kc::format::{self, Record};
use kc::write::{CreateOptions, NewIdentity, NewItem, create, now_timestamp};
use kc::{Error, Item, KeychainFile, Query, RecordType, Result};
#[derive(Parser)]
#[command(
name = "kc",
version,
about = "Create and read macOS keychain files without the Security framework",
long_about = "Create and read macOS keychain (.keychain) files directly.\n\n\
The file format is implemented here: no Security framework, no securityd, \
no entitlements. Files this writes can be read by `security`, and files \
`security` writes can be read by this.",
disable_help_subcommand = true
)]
struct Cli {
#[arg(long, global = true, value_enum, value_name = "FORMAT")]
format: Option<Format>,
#[arg(long, global = true, action = ArgAction::SetTrue, conflicts_with = "format")]
json: bool,
#[command(subcommand)]
command: Command,
}
#[derive(Copy, Clone, PartialEq, Eq, ValueEnum)]
enum Format {
Text,
Json,
Secret,
}
impl Cli {
fn format(&self) -> Format {
match (self.format, self.json) {
(Some(format), _) => format,
(None, true) => Format::Json,
(None, false) => Format::Text,
}
}
fn is_json(&self) -> bool {
self.format() == Format::Json
}
fn secrets_only(&self) -> bool {
self.format() == Format::Secret
}
}
#[derive(Args, Clone, Debug, Default)]
struct PasswordSource {
#[arg(
short = 'e',
long = "password-env",
value_name = "NAME",
group = "password-source"
)]
password_env: Option<String>,
#[arg(
short = 'f',
long = "password-file",
value_name = "FILE",
group = "password-source"
)]
password_file: Option<PathBuf>,
}
impl PasswordSource {
fn is_explicit(&self) -> bool {
self.password_env.is_some() || self.password_file.is_some()
}
fn resolve(&self, confirm: bool) -> Result<String> {
if let Some(name) = &self.password_env {
let value = std::env::var(name)
.map_err(|_| Error::other(format!("the environment variable {name} is not set")))?;
return Ok(first_line(&value).to_string());
}
if let Some(path) = &self.password_file {
if path.as_os_str() == "-" {
return read_password_line();
}
let text = std::fs::read_to_string(path).map_err(|source| {
Error::io(format!("could not read {}", path.display()), source)
})?;
return Ok(first_line(&text).to_string());
}
if !std::io::stdin().is_terminal() {
return read_password_line();
}
let password = rpassword::prompt_password("Keychain password: ")
.map_err(|source| Error::io("could not read the password", source))?;
if confirm {
let again = rpassword::prompt_password("Confirm: ")
.map_err(|source| Error::io("could not read the password", source))?;
if password != again {
return Err(Error::other("the two entries did not match"));
}
}
Ok(password)
}
fn resolve_optional(&self, required: bool) -> Result<Option<String>> {
if !required && !self.is_explicit() {
return Ok(None);
}
self.resolve(false).map(Some)
}
}
fn first_line(text: &str) -> &str {
let line = text.split('\n').next().unwrap_or(text);
line.strip_suffix('\r').unwrap_or(line)
}
fn read_password_line() -> Result<String> {
let mut line = String::new();
std::io::stdin()
.read_line(&mut line)
.map_err(|source| Error::io("could not read the password from stdin", source))?;
Ok(first_line(&line).to_string())
}
#[derive(Subcommand)]
enum Command {
Create {
#[command(flatten)]
password: PasswordSource,
#[arg(long, default_value_t = 300)]
idle_timeout: u32,
#[arg(long, action = ArgAction::SetTrue)]
no_lock_on_sleep: bool,
keychain: PathBuf,
},
Info { keychain: PathBuf },
Show {
#[command(flatten)]
password: PasswordSource,
#[arg(short = 'd', long, action = ArgAction::SetTrue)]
secrets: bool,
#[arg(long, action = ArgAction::SetTrue)]
all: bool,
keychain: PathBuf,
},
Ls {
#[command(flatten)]
password: PasswordSource,
keychain: PathBuf,
},
Verify {
#[command(flatten)]
password: PasswordSource,
keychain: PathBuf,
},
Add {
#[command(subcommand)]
kind: AddKind,
},
Find {
#[command(subcommand)]
kind: FindKind,
},
Completions {
#[arg(value_enum)]
shell: clap_complete::Shell,
},
}
#[derive(Subcommand)]
enum AddKind {
Generic {
#[command(flatten)]
password: PasswordSource,
#[arg(short = 'a', long)]
account: String,
#[arg(short = 's', long)]
service: String,
#[arg(short = 'G', long)]
generic: Option<String>,
#[arg(short = 'w', long)]
secret: Option<String>,
#[arg(short = 'l', long)]
label: Option<String>,
#[arg(short = 'D', long)]
kind: Option<String>,
#[arg(short = 'j', long)]
comment: Option<String>,
#[arg(short = 'T', long = "trust-app", value_name = "PATH")]
trust_apps: Vec<PathBuf>,
#[arg(long = "trust-requirement", value_name = "PATH=FILE")]
trust_requirements: Vec<String>,
keychain: PathBuf,
},
Internet {
#[command(flatten)]
password: PasswordSource,
#[arg(short = 'a', long)]
account: String,
#[arg(short = 's', long)]
server: String,
#[arg(short = 'S', short_alias = 'd', long)]
security_domain: Option<String>,
#[arg(short = 'w', long)]
secret: Option<String>,
#[arg(short = 'l', long)]
label: Option<String>,
#[arg(long)]
path: Option<String>,
#[arg(short = 'P', long)]
port: Option<u32>,
#[arg(short = 'r', long)]
protocol: Option<String>,
#[arg(short = 't', long)]
auth_type: Option<String>,
#[arg(short = 'D', long)]
kind: Option<String>,
#[arg(short = 'j', long)]
comment: Option<String>,
#[arg(short = 'T', long = "trust-app", value_name = "PATH")]
trust_apps: Vec<PathBuf>,
#[arg(long = "trust-requirement", value_name = "PATH=FILE")]
trust_requirements: Vec<String>,
keychain: PathBuf,
},
#[command(name = "appleshare")]
AppleShare {
#[command(flatten)]
password: PasswordSource,
#[arg(short = 'a', long)]
account: String,
#[arg(short = 'v', long)]
volume: String,
#[arg(short = 'w', long)]
secret: Option<String>,
#[arg(short = 'l', long)]
label: Option<String>,
#[arg(short = 's', long)]
server: Option<String>,
#[arg(long)]
address: Option<String>,
#[arg(long)]
signature: Option<String>,
#[arg(short = 'r', long)]
protocol: Option<String>,
#[arg(short = 'D', long)]
kind: Option<String>,
#[arg(short = 'j', long)]
comment: Option<String>,
#[arg(short = 'T', long = "trust-app", value_name = "PATH")]
trust_apps: Vec<PathBuf>,
#[arg(long = "trust-requirement", value_name = "PATH=FILE")]
trust_requirements: Vec<String>,
keychain: PathBuf,
},
Identity {
#[command(flatten)]
password: PasswordSource,
#[arg(short = 'c', long = "cert", value_name = "FILE")]
certificate: PathBuf,
#[arg(short = 'k', long = "key", value_name = "FILE")]
key: PathBuf,
#[arg(short = 'l', long)]
label: Option<String>,
#[arg(short = 'T', long = "trust-app", value_name = "PATH")]
trust_apps: Vec<PathBuf>,
#[arg(long = "trust-requirement", value_name = "PATH=FILE")]
trust_requirements: Vec<String>,
keychain: PathBuf,
},
}
#[derive(Subcommand)]
enum FindKind {
Generic {
#[command(flatten)]
password: PasswordSource,
#[arg(short = 'a', long)]
account: Option<String>,
#[arg(short = 's', long)]
service: Option<String>,
#[arg(short = 'G', long)]
generic: Option<String>,
#[arg(short = 'l', long)]
label: Option<String>,
#[arg(short = 'D', long)]
kind: Option<String>,
#[arg(short = 'j', long)]
comment: Option<String>,
#[arg(long = "attr", value_name = "NAME=VALUE")]
attributes: Vec<String>,
#[arg(short = 'w', long, action = ArgAction::SetTrue)]
secret_only: bool,
keychain: PathBuf,
},
Internet {
#[command(flatten)]
password: PasswordSource,
#[arg(short = 'a', long)]
account: Option<String>,
#[arg(short = 's', long)]
server: Option<String>,
#[arg(short = 'S', short_alias = 'd', long)]
security_domain: Option<String>,
#[arg(long)]
path: Option<String>,
#[arg(short = 'P', long)]
port: Option<u32>,
#[arg(short = 'l', long)]
label: Option<String>,
#[arg(short = 'D', long)]
kind: Option<String>,
#[arg(short = 'j', long)]
comment: Option<String>,
#[arg(long = "attr", value_name = "NAME=VALUE")]
attributes: Vec<String>,
#[arg(short = 'w', long, action = ArgAction::SetTrue)]
secret_only: bool,
keychain: PathBuf,
},
#[command(name = "appleshare")]
AppleShare {
#[command(flatten)]
password: PasswordSource,
#[arg(short = 'a', long)]
account: Option<String>,
#[arg(short = 'v', long)]
volume: Option<String>,
#[arg(short = 's', long)]
server: Option<String>,
#[arg(long)]
address: Option<String>,
#[arg(long)]
signature: Option<String>,
#[arg(short = 'l', long)]
label: Option<String>,
#[arg(short = 'D', long)]
kind: Option<String>,
#[arg(short = 'j', long)]
comment: Option<String>,
#[arg(long = "attr", value_name = "NAME=VALUE")]
attributes: Vec<String>,
#[arg(short = 'w', long, action = ArgAction::SetTrue)]
secret_only: bool,
keychain: PathBuf,
},
Identity {
#[arg(short = 'l', long)]
label: Option<String>,
keychain: PathBuf,
},
}
fn main() -> ExitCode {
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
let cli = Cli::parse();
match run(&cli) {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
if cli.is_json() {
let _ = writeln!(
std::io::stderr(),
"{}",
serde_json::json!({ "ok": false, "error": error.to_string() })
);
} else {
let _ = writeln!(std::io::stderr(), "kc: {error}");
}
ExitCode::from(error.exit_code())
}
}
}
fn run(cli: &Cli) -> Result<()> {
if cli.secrets_only() && !matches!(cli.command, Command::Find { .. } | Command::Show { .. }) {
return Err(Error::other(
"--format secret applies to `find` and `show`, which are the commands that read secrets",
));
}
match &cli.command {
Command::Create {
password,
idle_timeout,
no_lock_on_sleep,
keychain,
} => {
if keychain.exists() {
return Err(Error::other(format!(
"{} already exists",
keychain.display()
)));
}
let password = password.resolve(true)?;
let options = CreateOptions {
idle_timeout: *idle_timeout,
lock_on_sleep: !no_lock_on_sleep,
};
let file = create(password.as_bytes(), &options)?;
file.save(keychain)?;
report(
cli,
&format!("created {}", keychain.display()),
|| serde_json::json!({ "ok": true, "keychain": keychain }),
);
Ok(())
}
Command::Info { keychain } => info(cli, keychain),
Command::Show {
password,
secrets,
all,
keychain,
} => {
let secrets = *secrets || cli.secrets_only();
let mut file = KeychainFile::open(keychain)?;
if secrets {
let password = password.resolve(false)?;
file.unlock(password.as_bytes())?;
}
show(cli, &file, secrets, *all)
}
Command::Ls { password, keychain } => list_keys(cli, keychain, password),
Command::Verify { password, keychain } => verify(cli, keychain, password),
Command::Add { kind } => add_command(cli, kind),
Command::Find { kind } => find_command(cli, kind),
Command::Completions { shell } => {
use clap::CommandFactory;
let mut command = Cli::command();
let name = command.get_name().to_string();
clap_complete::generate(*shell, &mut command, name, &mut std::io::stdout());
Ok(())
}
}
}
fn add_command(cli: &Cli, kind: &AddKind) -> Result<()> {
match kind {
AddKind::Generic {
password,
account,
service,
generic,
secret,
label,
kind,
comment,
trust_apps,
trust_requirements,
keychain,
} => {
let item = NewItem {
label: label.clone(),
account: Some(account.clone()),
service: Some(service.clone()),
generic: generic.as_ref().map(|text| text.as_bytes().to_vec()),
description: kind.clone(),
comment: comment.clone(),
trusted_applications: trusted_applications(trust_apps, trust_requirements)?,
..NewItem::default()
};
add(
cli,
keychain,
password,
RecordType::GENERIC_PASSWORD,
item,
secret,
)
}
AddKind::Internet {
password,
account,
server,
security_domain,
secret,
label,
path,
port,
protocol,
auth_type,
kind,
comment,
trust_apps,
trust_requirements,
keychain,
} => {
let item = NewItem {
label: label.clone(),
account: Some(account.clone()),
server: Some(server.clone()),
security_domain: security_domain.clone(),
path: path.clone(),
port: *port,
description: kind.clone(),
comment: comment.clone(),
trusted_applications: trusted_applications(trust_apps, trust_requirements)?,
protocol: four_char_code(protocol.as_deref())?,
auth_type: four_char_code(auth_type.as_deref())?.or(Some(*b"dflt")),
..NewItem::default()
};
add(
cli,
keychain,
password,
RecordType::INTERNET_PASSWORD,
item,
secret,
)
}
AddKind::AppleShare {
password,
account,
volume,
secret,
label,
server,
address,
signature,
protocol,
kind,
comment,
trust_apps,
trust_requirements,
keychain,
} => {
let item = NewItem {
label: label.clone(),
account: Some(account.clone()),
volume: Some(volume.clone()),
server: server.clone(),
address: address.clone(),
signature: signature.clone(),
protocol: four_char_code(protocol.as_deref())?,
description: kind.clone(),
comment: comment.clone(),
trusted_applications: trusted_applications(trust_apps, trust_requirements)?,
..NewItem::default()
};
add(
cli,
keychain,
password,
RecordType::APPLESHARE_PASSWORD,
item,
secret,
)
}
AddKind::Identity {
password,
certificate,
key,
label,
trust_apps,
trust_requirements,
keychain,
} => {
let identity = NewIdentity {
certificate: read_der(certificate)?,
private_key: read_der(key)?,
label: label.clone(),
trusted_applications: trusted_applications(trust_apps, trust_requirements)?,
};
add_identity(cli, keychain, password, &identity)
}
}
}
fn find_command(cli: &Cli, kind: &FindKind) -> Result<()> {
match kind {
FindKind::Generic {
password,
account,
service,
generic,
label,
kind,
comment,
attributes,
secret_only,
keychain,
} => {
let query = Query {
record_type: Some(RecordType::GENERIC_PASSWORD),
account: account.clone(),
service: service.clone(),
generic: generic.clone(),
label: label.clone(),
description: kind.clone(),
comment: comment.clone(),
attributes: attribute_filters(attributes)?,
..Query::default()
};
find(cli, keychain, password, &query, *secret_only)
}
FindKind::Internet {
password,
account,
server,
security_domain,
path,
port,
label,
kind,
comment,
attributes,
secret_only,
keychain,
} => {
let query = Query {
record_type: Some(RecordType::INTERNET_PASSWORD),
account: account.clone(),
server: server.clone(),
security_domain: security_domain.clone(),
path: path.clone(),
port: *port,
label: label.clone(),
description: kind.clone(),
comment: comment.clone(),
attributes: attribute_filters(attributes)?,
..Query::default()
};
find(cli, keychain, password, &query, *secret_only)
}
FindKind::AppleShare {
password,
account,
volume,
server,
address,
signature,
label,
kind,
comment,
attributes,
secret_only,
keychain,
} => {
let query = Query {
record_type: Some(RecordType::APPLESHARE_PASSWORD),
account: account.clone(),
volume: volume.clone(),
server: server.clone(),
address: address.clone(),
signature: signature.clone(),
label: label.clone(),
description: kind.clone(),
comment: comment.clone(),
attributes: attribute_filters(attributes)?,
..Query::default()
};
find(cli, keychain, password, &query, *secret_only)
}
FindKind::Identity { label, keychain } => find_identity(cli, keychain, label.as_deref()),
}
}
fn info(cli: &Cli, keychain: &Path) -> Result<()> {
let file = KeychainFile::open(keychain)?;
let info = file.info()?;
if cli.is_json() {
println!(
"{}",
kc::output::pretty(&serde_json::json!({
"ok": true,
"keychain": keychain,
"format_version": format!("0x{:08x}", info.version),
"blob_version": format!("0x{:08x}", info.blob_version),
"sequence": info.sequence,
"idle_timeout": info.idle_timeout,
"lock_on_sleep": info.lock_on_sleep,
"salt": info.salt,
"iv": info.iv,
"pbkdf2_iterations": info.pbkdf2_iterations,
"tables": info.tables.iter().map(|(record_type, count)| serde_json::json!({
"record_type": format!("0x{:08x}", record_type.0),
"name": record_type.name(),
"records": count,
})).collect::<Vec<_>>(),
}))
);
return Ok(());
}
println!(
"{}",
kc::output::field_list(&[
("keychain", keychain.display().to_string()),
("format version", format!("0x{:08x}", info.version)),
("blob version", format!("0x{:08x}", info.blob_version)),
("sequence", info.sequence.to_string()),
("idle timeout", format!("{}s", info.idle_timeout)),
("lock on sleep", info.lock_on_sleep.to_string()),
(
"key derivation",
format!(
"PBKDF2-HMAC-SHA1, {} iterations, 3DES",
info.pbkdf2_iterations
)
),
("salt", info.salt),
("iv", info.iv),
])
);
println!("\ntables:");
for (record_type, count) in &info.tables {
println!(
" 0x{:08x} {count:>4} records {}",
record_type.0,
record_type.name()
);
}
Ok(())
}
fn add(
cli: &Cli,
keychain: &Path,
password: &PasswordSource,
record_type: RecordType,
item: NewItem,
secret: &Option<String>,
) -> Result<()> {
let mut file = KeychainFile::open(keychain)?;
let password = password.resolve(false)?;
file.unlock(password.as_bytes())?;
let secret = item_secret(secret)?;
file.add_password(record_type, &item, secret.as_bytes(), &now_timestamp())?;
file.save(keychain)?;
report(cli, "stored", || {
serde_json::json!({
"ok": true,
"account": item.account,
"service": item.service,
"server": item.server,
"volume": item.volume,
})
});
Ok(())
}
fn add_identity(
cli: &Cli,
keychain: &Path,
password: &PasswordSource,
identity: &NewIdentity,
) -> Result<()> {
let mut file = KeychainFile::open(keychain)?;
let password = password.resolve(false)?;
file.unlock(password.as_bytes())?;
let public_key_hash = file.add_identity(identity)?;
file.save(keychain)?;
report(cli, "stored", || {
serde_json::json!({
"ok": true,
"label": identity.label,
"public_key_hash": hex::encode(public_key_hash),
})
});
Ok(())
}
fn read_der(path: &Path) -> Result<Vec<u8>> {
let bytes = std::fs::read(path)
.map_err(|source| Error::io(format!("could not read {}", path.display()), source))?;
kc::der::pem_or_der(&bytes)
}
fn find(
cli: &Cli,
keychain: &Path,
password: &PasswordSource,
query: &Query,
secret_only: bool,
) -> Result<()> {
let mut file = KeychainFile::open(keychain)?;
let secret_only = secret_only || cli.secrets_only();
let unlocked = match password.resolve_optional(secret_only)? {
Some(password) => {
file.unlock(password.as_bytes())?;
true
}
None => false,
};
let item = file.find_one(query)?;
let secret = if unlocked {
Some(file.secret(&item)?)
} else {
None
};
if secret_only {
let secret = secret.ok_or(Error::Locked)?;
let mut stdout = std::io::stdout();
stdout
.write_all(secret.as_slice())
.and_then(|()| stdout.write_all(b"\n"))
.map_err(|source| Error::io("could not write the secret", source))?;
return Ok(());
}
if cli.is_json() {
let rendered = render_item(&item, secret.as_ref().map(|secret| secret.as_slice()));
println!(
"{}",
kc::output::pretty(&serde_json::json!({ "ok": true, "item": rendered }))
);
return Ok(());
}
let mut fields: Vec<(&str, String)> = vec![(
"class",
item.record_type.short_name().unwrap_or("?").to_string(),
)];
for (name, value) in item.attributes() {
fields.push((name, display_attribute(name, value)));
}
if let Some(secret) = &secret {
fields.push((
"secret",
String::from_utf8_lossy(secret.as_slice()).into_owned(),
));
}
println!("{}", kc::output::field_list(&fields));
Ok(())
}
fn find_identity(cli: &Cli, keychain: &Path, label: Option<&str>) -> Result<()> {
let file = KeychainFile::open(keychain)?;
let identities = identities_in(&file, label);
if identities.is_empty() {
return Err(Error::NoSuchItem);
}
if cli.is_json() {
println!(
"{}",
kc::output::pretty(&serde_json::json!({
"ok": true,
"identities": identities.iter().map(|identity| serde_json::json!({
"label": identity.label,
"private_key": identity.private_key,
"certificate": identity.certificate,
"public_key": identity.public_key,
})).collect::<Vec<_>>(),
}))
);
return Ok(());
}
if identities.len() == 1 {
let identity = &identities[0];
let mut fields: Vec<(&str, String)> = vec![("class", "identity".to_string())];
if let Some(label) = &identity.label {
fields.push(("label", label.clone()));
}
fields.push(("private key", format!("record {}", identity.private_key)));
if let Some(certificate) = identity.certificate {
fields.push(("certificate", format!("record {certificate}")));
}
if let Some(public_key) = identity.public_key {
fields.push(("public key", format!("record {public_key}")));
}
println!("{}", kc::output::field_list(&fields));
return Ok(());
}
println!("{:<7} LABEL", "MATCH");
for (index, identity) in identities.iter().enumerate() {
println!(
"{:<7} {}",
index + 1,
identity.label.as_deref().unwrap_or("-")
);
}
Err(Error::other(format!(
"{} identities match; narrow the query with -l",
identities.len()
)))
}
#[derive(Debug)]
struct IdentityMatch {
label: Option<String>,
private_key: u32,
certificate: Option<u32>,
public_key: Option<u32>,
}
fn identities_in(file: &KeychainFile, label_filter: Option<&str>) -> Vec<IdentityMatch> {
let schema = file.schema();
let label_of = |record_type: RecordType, record: &Record| {
schema
.attribute(record_type, record, "Label")
.and_then(|value| value.as_bytes())
.map(|bytes| bytes.to_vec())
};
let print_name_of = |record_type: RecordType, record: &Record| {
schema
.attribute(record_type, record, "PrintName")
.and_then(|value| value.as_bytes())
.map(|bytes| String::from_utf8_lossy(format::trim_nul(bytes)).into_owned())
.filter(|name| !name.is_empty())
};
let mut cert_by_label = std::collections::BTreeMap::<Vec<u8>, u32>::new();
for record_type in [RecordType::X509_CERTIFICATE, RecordType::CERT] {
for record in file.records_of_type(record_type) {
let key = schema
.attribute(record_type, record, "PublicKeyHash")
.and_then(|value| value.as_bytes())
.map(<[u8]>::to_vec)
.or_else(|| label_of(record_type, record));
if let Some(key) = key {
cert_by_label.insert(key, record.number);
}
}
}
let mut public_by_label = std::collections::BTreeMap::<Vec<u8>, u32>::new();
for record in file.records_of_type(RecordType::PUBLIC_KEY) {
if let Some(label) = label_of(RecordType::PUBLIC_KEY, record) {
public_by_label.insert(label, record.number);
}
}
let mut identities = Vec::new();
for record in file.records_of_type(RecordType::PRIVATE_KEY) {
let Some(label_bytes) = label_of(RecordType::PRIVATE_KEY, record) else {
continue;
};
let certificate = cert_by_label.get(&label_bytes).copied();
let public_key = public_by_label.get(&label_bytes).copied();
if certificate.is_none() && public_key.is_none() {
continue;
}
let label = print_name_of(RecordType::PRIVATE_KEY, record);
if let Some(wanted) = label_filter
&& label.as_deref() != Some(wanted)
{
continue;
}
identities.push(IdentityMatch {
label,
private_key: record.number,
certificate,
public_key,
});
}
identities
}
fn show(cli: &Cli, file: &KeychainFile, secrets: bool, all: bool) -> Result<()> {
let items = file.items();
if cli.secrets_only() {
let mut stdout = std::io::stdout();
for item in &items {
let secret = file.secret(item)?;
stdout
.write_all(secret.as_slice())
.and_then(|()| stdout.write_all(b"\n"))
.map_err(|source| Error::io("could not write the secret", source))?;
}
return Ok(());
}
if cli.is_json() {
let rendered: Vec<_> = items
.iter()
.map(|item| {
let secret = if secrets {
file.secret(item).ok()
} else {
None
};
render_item(item, secret.as_ref().map(|secret| secret.as_slice()))
})
.collect();
let mut body = serde_json::json!({ "ok": true, "items": rendered });
if all {
body["relations"] = relations_json(file);
}
println!("{}", kc::output::pretty(&body));
return Ok(());
}
if items.is_empty() {
eprintln!("kc: no password items");
}
for item in &items {
println!(
"class: {} label: {}",
item.record_type.short_name().unwrap_or("?"),
item.label().unwrap_or_default()
);
for (name, value) in item.attributes() {
println!(" {name:<12} {}", display_attribute(name, value));
}
if secrets {
match file.secret(item) {
Ok(secret) => {
println!(
" {:<12} {}",
"secret",
String::from_utf8_lossy(secret.as_slice())
);
}
Err(error) => println!(" {:<12} <{error}>", "secret"),
}
}
}
if all {
println!("\nrelations:");
for table in &file.keychain().tables {
println!(
" 0x{:08x} {:>4} records {}",
table.record_type.0,
table.record_count(),
table.record_type.name()
);
}
}
Ok(())
}
fn list_keys(cli: &Cli, keychain: &Path, password: &PasswordSource) -> Result<()> {
let mut file = KeychainFile::open(keychain)?;
let password = password.resolve(false)?;
file.unlock(password.as_bytes())?;
let keys: Vec<_> = file
.records_of_type(RecordType::SYMMETRIC_KEY)
.iter()
.filter_map(|record| {
let blob = KeyBlob::parse(&record.key_data).ok()?;
Some(serde_json::json!({
"record": record.number,
"label": file
.schema()
.attribute(RecordType::SYMMETRIC_KEY, record, "Label")
.and_then(|value| value.as_bytes())
.map(hex::encode),
"item": blob.public_acl.item_name(),
"trusted_apps": blob.public_acl.trusted_paths(),
"algorithm": format!("0x{:x}", blob.header.algorithm_id),
"key_bits": blob.header.logical_key_size_in_bits,
"wrapped_bytes": blob.crypto_blob.len(),
}))
})
.collect();
if cli.is_json() {
println!(
"{}",
kc::output::pretty(&serde_json::json!({ "ok": true, "keys": keys }))
);
} else if keys.is_empty() {
eprintln!("kc: no item keys");
} else {
println!("{:<7} {:<40} {:<9} ITEM", "RECORD", "LABEL", "KEY");
for key in &keys {
println!(
"{:<7} {:<40} {:<9} {}",
key["record"],
key["label"].as_str().unwrap_or("-"),
format!("{} bits", key["key_bits"]),
key["item"].as_str().unwrap_or("-")
);
}
}
Ok(())
}
fn verify(cli: &Cli, keychain: &Path, password: &PasswordSource) -> Result<()> {
let mut file = KeychainFile::open(keychain)?;
let password = password.resolve(false)?;
let blob = file.db_blob()?;
let keys = blob.unlock(password.as_bytes())?;
let database_signature = blob.verify(keys.signing_key.as_slice());
file.unlock(password.as_bytes())?;
let mut verified = 0usize;
let mut total = 0usize;
for record in file.records_of_type(RecordType::SYMMETRIC_KEY) {
if let Ok(key_blob) = KeyBlob::parse(&record.key_data) {
total += 1;
if key_blob.verify(keys.signing_key.as_slice()) {
verified += 1;
}
}
}
let items = file.items();
let readable = items
.iter()
.filter(|item| file.secret(item).is_ok())
.count();
let tables = file.keychain().tables.len();
let indexes_understood = file
.keychain()
.tables
.iter()
.filter(|table| table.indexes.blob().is_some())
.count();
let ok = database_signature
&& verified == total
&& readable == items.len()
&& indexes_understood == tables;
if cli.is_json() {
println!(
"{}",
kc::output::pretty(&serde_json::json!({
"ok": ok,
"database_signature": database_signature,
"key_signatures_verified": verified,
"key_signatures_total": total,
"items": items.len(),
"items_readable": readable,
"tables": tables,
"index_regions_understood": indexes_understood,
}))
);
} else {
println!(
"{}",
kc::output::field_list(&[
("database signature", pass(database_signature)),
("key signatures", format!("{verified}/{total} verified")),
("items readable", format!("{readable}/{}", items.len())),
(
"index regions",
format!("{indexes_understood}/{tables} understood")
),
])
);
}
if ok {
Ok(())
} else {
Err(Error::other("keychain did not verify"))
}
}
fn relations_json(file: &KeychainFile) -> serde_json::Value {
serde_json::json!(
file.keychain()
.tables
.iter()
.map(|table| serde_json::json!({
"record_type": format!("0x{:08x}", table.record_type.0),
"name": table.record_type.name(),
"records": table.record_count(),
}))
.collect::<Vec<_>>()
)
}
fn display_attribute(name: &str, value: &kc::Value) -> String {
if kc::db::FOUR_CHAR_CODE_ATTRIBUTES.contains(&name)
&& let Some(number) = value.as_u32()
{
let bytes = number.to_be_bytes();
if bytes.iter().all(|byte| (0x20..0x7f).contains(byte)) {
return String::from_utf8_lossy(&bytes).into_owned();
}
}
value.to_display_string()
}
fn pass(value: bool) -> String {
if value {
"ok".to_string()
} else {
"FAILED".to_string()
}
}
fn render_item(item: &Item<'_>, secret: Option<&[u8]>) -> serde_json::Value {
let mut body = serde_json::json!({
"class": item.record_type.short_name(),
"record": item.number(),
"label": item.label(),
"account": item.account(),
"service": item.service(),
"server": item.server(),
"path": item.path(),
"port": item.port(),
"volume": item.volume(),
"address": item.address(),
"signature": item.signature(),
"created": item.created(),
"modified": item.modified(),
"has_secret": item.has_secret(),
});
if let Some(secret) = secret {
body["secret"] = serde_json::json!(String::from_utf8_lossy(secret));
}
body
}
fn report(cli: &Cli, text: &str, json: impl FnOnce() -> serde_json::Value) {
if cli.is_json() {
println!("{}", kc::output::pretty(&json()));
} else {
println!("{text}");
}
}
fn item_secret(flag: &Option<String>) -> Result<String> {
if let Some(secret) = flag {
return Ok(secret.clone());
}
if !std::io::stdin().is_terminal() {
let mut buffer = String::new();
std::io::stdin()
.read_to_string(&mut buffer)
.map_err(|source| Error::io("could not read the secret from stdin", source))?;
let secret = buffer.strip_suffix('\n').unwrap_or(&buffer);
return Ok(secret.strip_suffix('\r').unwrap_or(secret).to_string());
}
let secret = rpassword::prompt_password("Secret: ")
.map_err(|source| Error::io("could not read the secret", source))?;
let again = rpassword::prompt_password("Confirm: ")
.map_err(|source| Error::io("could not read the secret", source))?;
if secret != again {
return Err(Error::other("the two entries did not match"));
}
Ok(secret)
}
fn trusted_applications(
paths: &[PathBuf],
requirements: &[String],
) -> Result<Vec<kc::acl::TrustedApplication>> {
let mut applications = Vec::with_capacity(paths.len() + requirements.len());
for path in paths {
applications.push(kc::requirement::for_application(path)?);
}
for spec in requirements {
let (path, file) = spec
.split_once('=')
.ok_or_else(|| Error::other(format!("expected PATH=FILE, got {spec:?}")))?;
let blob = std::fs::read(file)
.map_err(|source| Error::io(format!("could not read {file}"), source))?;
applications.push(kc::requirement::from_blob(path, blob)?);
}
Ok(applications)
}
fn attribute_filters(specs: &[String]) -> Result<Vec<(String, String)>> {
specs
.iter()
.map(|spec| {
spec.split_once('=')
.map(|(name, value)| (name.to_string(), value.to_string()))
.ok_or_else(|| Error::other(format!("expected NAME=VALUE, got {spec:?}")))
})
.collect()
}
fn four_char_code(text: Option<&str>) -> Result<Option<[u8; 4]>> {
let Some(text) = text else {
return Ok(None);
};
let bytes = text.as_bytes();
if bytes.len() > 4 {
return Err(Error::other(format!(
"{text:?} is longer than four characters"
)));
}
let mut code = *b" ";
code[..bytes.len()].copy_from_slice(bytes);
Ok(Some(code))
}