use clap::{ArgAction, Args, Parser, Subcommand, ValueEnum};
use std::io::{BufRead, BufReader, IsTerminal, Read, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use keychain::crypto::KeyBlob;
use keychain::edit::{ItemChanges, Settings};
use keychain::format::{self, Record};
use keychain::write::{CreateOptions, NewIdentity, NewItem, create, now_timestamp};
use keychain::{Error, Item, KeychainFile, Query, RecordType, Result};
#[path = "../config.rs"]
mod config;
#[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,
#[arg(long, global = true, action = ArgAction::SetTrue)]
interactive: 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 = 'p',
long = "password",
value_name = "PASSWORD",
group = "password-source"
)]
password: Option<String>,
#[arg(
short = 'E',
visible_short_alias = 'e',
long = "password-env",
value_name = "ENV_VAR",
num_args = 0..=1,
default_missing_value = "KC_PASSWORD",
group = "password-source"
)]
password_env: Option<String>,
#[arg(
short = 'F',
visible_short_alias = 'f',
long = "password-file",
value_name = "FILE",
group = "password-source"
)]
password_file: Option<PathBuf>,
#[arg(
long = "password-gen",
value_name = "TEMPLATE",
num_args = 0..=1,
default_missing_value = "sha1",
require_equals = true,
group = "password-source"
)]
password_gen: Option<String>,
#[arg(long = "password-policy", value_enum, requires = "password_gen")]
password_policy: Option<PasswordPolicy>,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
enum PasswordPolicy {
Alphanumeric,
MixedCase,
Symbol,
Secure,
}
#[derive(Args, Clone, Debug, Default)]
struct Pkcs12PasswordSource {
#[arg(
id = "pkcs12-password",
long = "pkcs12-password",
value_name = "PASSWORD",
num_args = 0..=1,
group = "pkcs12-password-source"
)]
password: Option<Option<String>>,
#[arg(
id = "pkcs12-password-env",
long = "pkcs12-password-env",
value_name = "NAME",
group = "pkcs12-password-source"
)]
password_env: Option<String>,
#[arg(
id = "pkcs12-password-file",
long = "pkcs12-password-file",
value_name = "FILE",
group = "pkcs12-password-source"
)]
password_file: Option<PathBuf>,
}
impl Pkcs12PasswordSource {
fn resolve(&self) -> Result<String> {
if let Some(password) = &self.password {
return password.clone().map_or_else(
|| {
rpassword::prompt_password("PKCS#12 password: ")
.map_err(|source| Error::io("could not read the PKCS#12 password", source))
},
Ok,
);
}
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();
}
rpassword::prompt_password("PKCS#12 password: ")
.map_err(|source| Error::io("could not read the PKCS#12 password", source))
}
}
impl PasswordSource {
fn is_explicit(&self) -> bool {
self.password.is_some()
|| self.password_env.is_some()
|| self.password_file.is_some()
|| self.password_gen.is_some()
}
fn resolve(&self, confirm: bool) -> Result<String> {
if let Some(password) = &self.password {
return Ok(password.clone());
}
if let Some(template) = &self.password_gen {
if !confirm {
return Err(Error::other(
"--password-gen can only be used when setting a new password",
));
}
return generate_password(template, self.password_policy);
}
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 generate_password(template: &str, policy: Option<PasswordPolicy>) -> Result<String> {
match template {
"sha1" if policy.is_none() => Ok(hex::encode(keychain::secret::random_bytes(20))),
"sha1" => generate_policy_password(40, policy.expect("checked above")),
_ => Err(Error::other(format!(
"unknown password template {template:?}; expected sha1"
))),
}
}
fn generate_policy_password(length: usize, policy: PasswordPolicy) -> Result<String> {
const LOWER: &[u8] = b"abcdefghijklmnopqrstuvwxyz";
const UPPER: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const DIGITS: &[u8] = b"0123456789";
const SYMBOLS: &[u8] = b"!#$%&()*+,-./:;<=>?@[]^_{|}~";
let (alphabet, required): (Vec<u8>, &[&[u8]]) = match policy {
PasswordPolicy::Alphanumeric => ([LOWER, UPPER, DIGITS].concat(), &[]),
PasswordPolicy::MixedCase => ([LOWER, UPPER, DIGITS].concat(), &[LOWER, UPPER, DIGITS]),
PasswordPolicy::Symbol => ([LOWER, UPPER, DIGITS, SYMBOLS].concat(), &[]),
PasswordPolicy::Secure => (
[LOWER, UPPER, DIGITS, SYMBOLS].concat(),
&[LOWER, UPPER, DIGITS, SYMBOLS],
),
};
let mut output = Vec::with_capacity(length);
for class in required {
output.push(random_character(class));
}
while output.len() < length {
output.push(random_character(&alphabet));
}
for index in (1..output.len()).rev() {
let selected = random_index(index + 1);
output.swap(index, selected);
}
String::from_utf8(output).map_err(|_| Error::other("generated password was not UTF-8"))
}
fn random_character(alphabet: &[u8]) -> u8 {
alphabet[random_index(alphabet.len())]
}
fn random_index(upper: usize) -> usize {
let limit = 256 - (256 % upper);
loop {
let byte = keychain::secret::random_bytes(1)[0] as usize;
if byte < limit {
return byte % upper;
}
}
}
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())
}
fn resolve_keychain(keychain: &Option<PathBuf>) -> Result<PathBuf> {
config::Config::load()?.resolve(keychain.as_deref())
}
#[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: Option<PathBuf> },
Show {
#[command(flatten)]
password: PasswordSource,
#[arg(short = 'd', long, action = ArgAction::SetTrue)]
secrets: bool,
#[arg(long, action = ArgAction::SetTrue)]
all: bool,
keychain: Option<PathBuf>,
},
Ls {
#[command(flatten)]
password: PasswordSource,
keychain: Option<PathBuf>,
},
Verify {
#[command(flatten)]
password: PasswordSource,
keychain: Option<PathBuf>,
},
Add {
#[command(subcommand)]
kind: AddKind,
},
Import {
#[command(subcommand)]
kind: ImportKind,
},
Find {
#[command(subcommand)]
kind: FindKind,
},
Set {
#[command(flatten)]
password: PasswordSource,
#[command(flatten)]
selector: Selector,
#[arg(short = 'w', long, num_args = 0..=1, default_missing_value = "")]
secret: Option<String>,
#[arg(short = 'l', long = "set-label", value_name = "LABEL")]
new_label: Option<String>,
#[arg(short = 'D', long = "set-kind", value_name = "KIND")]
new_kind: Option<String>,
#[arg(short = 'j', long = "set-comment", value_name = "COMMENT")]
new_comment: Option<String>,
#[arg(short = 'G', long = "set-generic", value_name = "GENERIC")]
new_generic: Option<String>,
#[arg(long = "set", value_name = "NAME=VALUE")]
set_attributes: Vec<String>,
keychain: Option<PathBuf>,
},
#[command(visible_alias = "delete")]
Rm {
#[command(subcommand)]
kind: RmKind,
},
Trust {
#[command(flatten)]
password: PasswordSource,
#[command(flatten)]
selector: Selector,
#[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>,
#[arg(short = 'A', long, action = ArgAction::SetTrue)]
any: bool,
keychain: Option<PathBuf>,
},
Cp {
#[command(flatten)]
password: PasswordSource,
#[command(flatten)]
selector: Selector,
#[arg(
long = "to-password",
value_name = "PASSWORD",
num_args = 0..=1,
group = "to-password-source"
)]
to_password: Option<Option<String>>,
#[arg(
long = "to-password-env",
value_name = "NAME",
group = "to-password-source"
)]
to_password_env: Option<String>,
#[arg(
long = "to-password-file",
value_name = "FILE",
group = "to-password-source"
)]
to_password_file: Option<PathBuf>,
#[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>,
source: PathBuf,
destination: PathBuf,
},
Export {
#[command(subcommand)]
kind: ExportKind,
},
#[command(visible_alias = "change-password")]
Passwd {
#[command(flatten)]
password: PasswordSource,
#[arg(
long = "new-password",
value_name = "PASSWORD",
num_args = 0..=1,
group = "new-password-source"
)]
new_password: Option<Option<String>>,
#[arg(long = "new-password-gen", value_name = "TEMPLATE", num_args = 0..=1, default_missing_value = "sha1", require_equals = true, group = "new-password-source")]
new_password_gen: Option<String>,
#[arg(
long = "new-password-policy",
value_enum,
requires = "new_password_gen"
)]
new_password_policy: Option<PasswordPolicy>,
#[arg(
long = "new-password-env",
value_name = "NAME",
group = "new-password-source"
)]
new_password_env: Option<String>,
#[arg(
long = "new-password-file",
value_name = "FILE",
group = "new-password-source"
)]
new_password_file: Option<PathBuf>,
keychain: Option<PathBuf>,
},
Settings {
#[command(flatten)]
password: PasswordSource,
#[arg(short = 't', long, conflicts_with = "no_timeout")]
idle_timeout: Option<u32>,
#[arg(long, action = ArgAction::SetTrue)]
no_timeout: bool,
#[arg(short = 'l', long, action = ArgAction::SetTrue)]
lock_on_sleep: bool,
#[arg(long, action = ArgAction::SetTrue, conflicts_with = "lock_on_sleep")]
no_lock_on_sleep: bool,
keychain: Option<PathBuf>,
},
Completions {
#[arg(value_enum)]
shell: clap_complete::Shell,
},
Config {
#[command(subcommand)]
action: ConfigAction,
},
Access {
#[command(subcommand)]
action: AccessAction,
},
}
#[derive(Subcommand)]
enum ConfigAction {
Show,
Set {
property: String,
#[arg(required = true, num_args = 1..)]
values: Vec<String>,
},
Append {
property: String,
#[arg(required = true, num_args = 1..)]
values: Vec<String>,
},
Prepend {
property: String,
#[arg(required = true, num_args = 1..)]
values: Vec<String>,
},
}
#[derive(Subcommand)]
enum AccessAction {
Show { keychain: Option<PathBuf> },
Set {
#[arg(long, value_enum)]
default: Option<CliAccessDefault>,
#[arg(long, value_enum)]
mode: Option<CliAccessMode>,
#[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>,
#[arg(long, action = ArgAction::SetTrue)]
clear_trust: bool,
keychain: Option<PathBuf>,
},
Clear { keychain: Option<PathBuf> },
Apply {
#[command(flatten)]
password: PasswordSource,
#[arg(long, value_enum, default_value = "securityd")]
to: AccessProjection,
keychain: Option<PathBuf>,
},
Audit {
#[arg(long, value_enum, default_value = "securityd")]
against: AccessProjection,
keychain: Option<PathBuf>,
},
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
enum CliAccessMode {
Extended,
Native,
Hybrid,
}
impl From<CliAccessMode> for keychain::AccessMode {
fn from(value: CliAccessMode) -> Self {
match value {
CliAccessMode::Extended => Self::Extended,
CliAccessMode::Native => Self::Native,
CliAccessMode::Hybrid => Self::Hybrid,
}
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
enum CliAccessDefault {
Allow,
Prompt,
Deny,
}
impl From<CliAccessDefault> for keychain::AccessDefault {
fn from(value: CliAccessDefault) -> Self {
match value {
CliAccessDefault::Allow => Self::Allow,
CliAccessDefault::Prompt => Self::Prompt,
CliAccessDefault::Deny => Self::Deny,
}
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
enum AccessProjection {
Securityd,
}
#[derive(Subcommand)]
enum ImportKind {
Identity {
#[command(flatten)]
password: PasswordSource,
#[command(flatten)]
pkcs12_password: Pkcs12PasswordSource,
#[arg(short = 'l', visible_short_alias = '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>,
input: PathBuf,
keychain: Option<PathBuf>,
},
}
#[derive(Args, Clone, Debug, Default)]
struct Selector {
#[arg(short = 'c', long, value_enum)]
class: Option<ItemClass>,
#[arg(short = 'a', long)]
account: Option<String>,
#[arg(short = 's', visible_short_alias = 'S', long)]
service: Option<String>,
#[arg(long)]
server: Option<String>,
#[arg(long)]
security_domain: Option<String>,
#[arg(long)]
path: Option<String>,
#[arg(short = 'P', long)]
port: Option<u32>,
#[arg(short = 'v', long)]
volume: Option<String>,
#[arg(short = 'L', long)]
label: Option<String>,
#[arg(long)]
kind: Option<String>,
#[arg(short = 'C', long)]
comment: Option<String>,
#[arg(long)]
generic: Option<String>,
#[arg(long = "attr", value_name = "NAME=VALUE")]
attributes: Vec<String>,
}
impl Selector {
fn to_query(&self) -> Result<Query> {
Ok(Query {
record_type: self.class.map(ItemClass::record_type),
account: self.account.clone(),
service: self.service.clone(),
server: self.server.clone(),
security_domain: self.security_domain.clone(),
path: self.path.clone(),
port: self.port,
volume: self.volume.clone(),
label: self.label.clone(),
description: self.kind.clone(),
comment: self.comment.clone(),
generic: self.generic.clone(),
attributes: attribute_filters(&self.attributes)?,
..Query::default()
})
}
fn is_empty(&self) -> bool {
self.to_query()
.map(|query| query.is_empty())
.unwrap_or(false)
&& self.class.is_none()
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
enum ItemClass {
Generic,
Internet,
#[value(name = "appleshare")]
AppleShare,
}
impl ItemClass {
fn record_type(self) -> RecordType {
match self {
Self::Generic => RecordType::GENERIC_PASSWORD,
Self::Internet => RecordType::INTERNET_PASSWORD,
Self::AppleShare => RecordType::APPLESHARE_PASSWORD,
}
}
}
#[derive(Subcommand)]
#[allow(clippy::large_enum_variant)] enum RmKind {
#[command(name = "item")]
Item {
#[command(flatten)]
password: PasswordSource,
#[command(flatten)]
selector: Selector,
#[arg(long, action = ArgAction::SetTrue)]
all: bool,
keychain: Option<PathBuf>,
},
Identity {
#[command(flatten)]
password: PasswordSource,
#[arg(short = 'l', visible_short_alias = 'L', long)]
label: Option<String>,
#[arg(long)]
hash: Option<String>,
keychain: Option<PathBuf>,
},
#[command(name = "cert", visible_alias = "certificate")]
Certificate {
#[command(flatten)]
password: PasswordSource,
#[arg(short = 'l', visible_short_alias = 'L', long)]
label: Option<String>,
#[arg(long)]
hash: Option<String>,
keychain: Option<PathBuf>,
},
}
#[derive(Subcommand)]
enum ExportKind {
#[command(name = "cert", visible_alias = "certificate")]
Certificate {
#[command(flatten)]
password: PasswordSource,
#[arg(short = 'l', visible_short_alias = 'L', long)]
label: Option<String>,
#[arg(long, action = ArgAction::SetTrue)]
der: bool,
#[arg(short = 'o', long)]
out: Option<PathBuf>,
keychain: Option<PathBuf>,
},
Key {
#[command(flatten)]
password: PasswordSource,
#[arg(short = 'l', visible_short_alias = 'L', long)]
label: Option<String>,
#[arg(long, action = ArgAction::SetTrue)]
der: bool,
#[arg(short = 'o', long)]
out: Option<PathBuf>,
keychain: Option<PathBuf>,
},
Identity {
#[command(flatten)]
password: PasswordSource,
#[arg(short = 'l', visible_short_alias = 'L', long)]
label: Option<String>,
#[arg(short = 'o', long)]
out: Option<PathBuf>,
#[arg(long, action = ArgAction::SetTrue)]
pkcs12: bool,
#[arg(long, action = ArgAction::SetTrue, requires = "pkcs12")]
pem: bool,
#[command(flatten)]
pkcs12_password: Pkcs12PasswordSource,
keychain: Option<PathBuf>,
},
}
#[derive(Subcommand)]
enum AddKind {
Generic {
#[command(flatten)]
password: PasswordSource,
#[arg(short = 'a', visible_short_alias = 'A', long)]
account: String,
#[arg(short = 's', visible_short_alias = 'S', long)]
service: String,
#[arg(short = 'G', long)]
generic: Option<String>,
#[arg(short = 'w', long)]
secret: Option<String>,
#[arg(short = 'l', visible_short_alias = 'L', long)]
label: Option<String>,
#[arg(short = 'D', long)]
kind: Option<String>,
#[arg(short = 'j', visible_short_alias = 'C', 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: Option<PathBuf>,
},
Internet {
#[command(flatten)]
password: PasswordSource,
#[arg(short = 'a', visible_short_alias = 'A', long)]
account: String,
#[arg(short = 's', long)]
server: String,
#[arg(short = 'S', visible_short_alias = 'd', long)]
security_domain: Option<String>,
#[arg(short = 'w', long)]
secret: Option<String>,
#[arg(short = 'l', visible_short_alias = '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', visible_short_alias = 'C', 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: Option<PathBuf>,
},
#[command(name = "appleshare")]
AppleShare {
#[command(flatten)]
password: PasswordSource,
#[arg(short = 'a', visible_short_alias = 'A', long)]
account: String,
#[arg(short = 'v', long)]
volume: String,
#[arg(short = 'w', long)]
secret: Option<String>,
#[arg(short = 'l', visible_short_alias = 'L', long)]
label: Option<String>,
#[arg(short = 's', visible_short_alias = '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', visible_short_alias = 'C', 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: Option<PathBuf>,
},
Identity {
#[command(flatten)]
password: PasswordSource,
#[arg(
short = 'c',
long = "cert",
value_name = "FILE",
required_unless_present = "pkcs12",
requires = "key",
conflicts_with = "pkcs12"
)]
certificate: Option<PathBuf>,
#[arg(
short = 'k',
long = "key",
value_name = "FILE",
required_unless_present = "pkcs12",
requires = "certificate",
conflicts_with = "pkcs12"
)]
key: Option<PathBuf>,
#[arg(
long = "pkcs12",
visible_alias = "pfx",
value_name = "FILE",
required_unless_present_all = ["certificate", "key"]
)]
pkcs12: Option<PathBuf>,
#[command(flatten)]
pkcs12_password: Pkcs12PasswordSource,
#[arg(short = 'l', visible_short_alias = '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: Option<PathBuf>,
},
}
#[derive(Subcommand)]
enum FindKind {
Generic {
#[command(flatten)]
password: PasswordSource,
#[arg(short = 'a', visible_short_alias = 'A', long)]
account: Option<String>,
#[arg(short = 's', visible_short_alias = 'S', long)]
service: Option<String>,
#[arg(short = 'G', long)]
generic: Option<String>,
#[arg(short = 'l', visible_short_alias = 'L', long)]
label: Option<String>,
#[arg(short = 'D', long)]
kind: Option<String>,
#[arg(short = 'j', visible_short_alias = 'C', 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: Option<PathBuf>,
},
Internet {
#[command(flatten)]
password: PasswordSource,
#[arg(short = 'a', visible_short_alias = 'A', long)]
account: Option<String>,
#[arg(short = 's', long)]
server: Option<String>,
#[arg(short = 'S', visible_short_alias = 'd', long)]
security_domain: Option<String>,
#[arg(long)]
path: Option<String>,
#[arg(short = 'P', long)]
port: Option<u32>,
#[arg(short = 'l', visible_short_alias = 'L', long)]
label: Option<String>,
#[arg(short = 'D', long)]
kind: Option<String>,
#[arg(short = 'j', visible_short_alias = 'C', 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: Option<PathBuf>,
},
#[command(name = "appleshare")]
AppleShare {
#[command(flatten)]
password: PasswordSource,
#[arg(short = 'a', visible_short_alias = 'A', long)]
account: Option<String>,
#[arg(short = 'v', long)]
volume: Option<String>,
#[arg(short = 's', visible_short_alias = 'S', long)]
server: Option<String>,
#[arg(long)]
address: Option<String>,
#[arg(long)]
signature: Option<String>,
#[arg(short = 'l', visible_short_alias = 'L', long)]
label: Option<String>,
#[arg(short = 'D', long)]
kind: Option<String>,
#[arg(short = 'j', visible_short_alias = 'C', 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: Option<PathBuf>,
},
Identity {
#[arg(short = 'l', visible_short_alias = 'L', long)]
label: Option<String>,
keychain: Option<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,
} => {
let keychain = resolve_keychain(&Some(keychain.clone()))?;
if keychain.exists() {
return Err(Error::other(format!(
"{} already exists",
keychain.display()
)));
}
let generated_password = password.password_gen.is_some();
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)?;
let generated = generated_password.then_some(password.as_str());
report(
cli,
&generated.map_or_else(
|| format!("created {}", keychain.display()),
|value| format!("created {}\npassword: {value}", keychain.display()),
),
|| {
serde_json::json!({
"ok": true,
"keychain": keychain,
"generated_password": generated,
})
},
);
Ok(())
}
Command::Info { keychain } => info(cli, &resolve_keychain(keychain)?),
Command::Show {
password,
secrets,
all,
keychain,
} => {
let keychain = resolve_keychain(keychain)?;
let secrets = *secrets || cli.secrets_only();
let mut file = KeychainFile::open(&keychain)?;
if secrets {
authorize_secret_access(cli, &keychain, "read secrets")?;
let password = password.resolve(false)?;
file.unlock(password.as_bytes())?;
}
show(cli, &file, secrets, *all)
}
Command::Ls { password, keychain } => {
list_keys(cli, &resolve_keychain(keychain)?, password)
}
Command::Verify { password, keychain } => {
verify(cli, &resolve_keychain(keychain)?, password)
}
Command::Add { kind } => add_command(cli, kind),
Command::Import { kind } => import_command(cli, kind),
Command::Find { kind } => find_command(cli, kind),
Command::Set {
password,
selector,
secret,
new_label,
new_kind,
new_comment,
new_generic,
set_attributes,
keychain,
} => set_command(
cli,
&resolve_keychain(keychain)?,
password,
selector,
secret.as_deref(),
&ItemChanges {
label: new_label.clone(),
description: new_kind.clone(),
comment: new_comment.clone(),
generic: new_generic.as_ref().map(|text| text.as_bytes().to_vec()),
security_domain: None,
attributes: attribute_assignments(set_attributes)?,
},
),
Command::Rm { kind } => rm_command(cli, kind),
Command::Trust {
password,
selector,
trust_apps,
trust_requirements,
any,
keychain,
} => {
let trusted = trusted_applications(trust_apps, trust_requirements)?;
if trusted.is_empty() && !any {
return Err(Error::other(
"name at least one application with -T, or pass -A to allow any",
));
}
if !trusted.is_empty() && *any {
return Err(Error::other(
"-A allows any application; -T restricts to one",
));
}
trust_command(
cli,
&resolve_keychain(keychain)?,
password,
selector,
&trusted,
)
}
Command::Cp {
password,
selector,
to_password,
to_password_env,
to_password_file,
trust_apps,
trust_requirements,
source,
destination,
} => {
let source = resolve_keychain(&Some(source.clone()))?;
let destination = resolve_keychain(&Some(destination.clone()))?;
let to = PasswordSource {
password: to_password.as_ref().and_then(Clone::clone),
password_env: to_password_env.clone(),
password_file: to_password_file.clone(),
password_gen: None,
password_policy: None,
};
let destination_password = if matches!(to_password, Some(None)) {
DestinationPassword::Prompt
} else {
DestinationPassword::Source(&to)
};
copy_command(
cli,
&source,
&destination,
password,
destination_password,
selector,
&trusted_applications_for_write(&destination, trust_apps, trust_requirements)?,
)
}
Command::Export { kind } => export_command(cli, kind),
Command::Passwd {
password,
new_password,
new_password_gen,
new_password_policy,
new_password_env,
new_password_file,
keychain,
} => {
let new = PasswordSource {
password: new_password.as_ref().and_then(Clone::clone),
password_env: new_password_env.clone(),
password_file: new_password_file.clone(),
password_gen: new_password_gen.clone(),
password_policy: *new_password_policy,
};
passwd_command(cli, &resolve_keychain(keychain)?, password, &new)
}
Command::Settings {
password,
idle_timeout,
no_timeout,
lock_on_sleep,
no_lock_on_sleep,
keychain,
} => settings_command(
cli,
&resolve_keychain(keychain)?,
password,
match (idle_timeout, no_timeout) {
(Some(seconds), _) => Some(*seconds),
(None, true) => Some(Settings::NO_TIMEOUT),
(None, false) => None,
},
match (*lock_on_sleep, *no_lock_on_sleep) {
(true, _) => Some(true),
(_, true) => Some(false),
_ => None,
},
),
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(())
}
Command::Config { action } => config_command(cli, action),
Command::Access { action } => access_command(cli, action),
}
}
fn config_command(cli: &Cli, action: &ConfigAction) -> Result<()> {
let mut config = config::Config::load()?;
match action {
ConfigAction::Show => {
let path = config::Config::path()?;
let search_paths = config.all_search_paths()?;
if cli.is_json() {
println!(
"{}",
serde_json::json!({
"ok": true,
"path": path,
"keychains": { "default": config.default },
"search": { "paths": search_paths },
})
);
} else {
println!("path: {}", path.display());
println!("keychains.default: {}", config.default);
for search_path in search_paths {
println!("search.paths: {}", search_path.display());
}
}
Ok(())
}
ConfigAction::Set { property, values } => match property.as_str() {
"keychains.default" => {
if values.len() != 1 {
return Err(Error::other("keychains.default takes exactly one keychain"));
}
config.default.clone_from(&values[0]);
let path = config.save()?;
report(cli, &format!("keychains.default: {}", values[0]), || {
serde_json::json!({
"ok": true,
"path": path,
"property": property,
"value": values[0],
})
});
Ok(())
}
"search.paths" => {
config.search_paths = values.iter().map(PathBuf::from).collect();
let path = config.save()?;
report(cli, &format!("search.paths: {}", values.join(", ")), || {
serde_json::json!({
"ok": true,
"path": path,
"property": property,
"value": values,
})
});
Ok(())
}
_ => Err(Error::other(format!(
"unknown configuration property {property:?}; expected keychains.default or search.paths"
))),
},
ConfigAction::Append { property, values } => {
update_list_property(cli, &mut config, property, values, false)
}
ConfigAction::Prepend { property, values } => {
update_list_property(cli, &mut config, property, values, true)
}
}
}
fn update_list_property(
cli: &Cli,
config: &mut config::Config,
property: &str,
values: &[String],
prepend: bool,
) -> Result<()> {
if property != "search.paths" {
return Err(Error::other(format!(
"{property:?} is not a list property; append and prepend support search.paths"
)));
}
let mut additions = Vec::new();
for value in values {
let path = PathBuf::from(value);
if !additions.contains(&path) {
additions.push(path);
}
}
config.search_paths.retain(|path| !additions.contains(path));
if prepend {
let mut paths = additions;
paths.append(&mut config.search_paths);
config.search_paths = paths;
} else {
config.search_paths.extend(additions);
}
let path = config.save()?;
let operation = if prepend { "prepend" } else { "append" };
report(
cli,
&format!("{operation} search.paths: {}", values.join(", ")),
|| {
serde_json::json!({
"ok": true,
"path": path,
"operation": operation,
"property": property,
"value": config.search_paths,
})
},
);
Ok(())
}
fn access_command(cli: &Cli, action: &AccessAction) -> Result<()> {
let mut config = config::Config::load()?;
match action {
AccessAction::Show { keychain } => {
let path = config.resolve(keychain.as_deref())?;
let policy = config.access_policy_for(&path)?;
let Some(policy) = policy else {
return Err(Error::other(format!(
"{} has no configured access policy",
path.display()
)));
};
report_access_policy(cli, &path, policy);
Ok(())
}
AccessAction::Set {
default,
mode,
trust_apps,
trust_requirements,
clear_trust,
keychain,
} => {
if *clear_trust && (!trust_apps.is_empty() || !trust_requirements.is_empty()) {
return Err(Error::other(
"--clear-trust cannot be combined with trusted applications",
));
}
let path = config.resolve(keychain.as_deref())?;
let existing = config.access_policy_for(&path)?.cloned();
let selector = existing.as_ref().map_or_else(
|| {
keychain.as_ref().map_or_else(
|| config.default.clone(),
|path| path.to_string_lossy().into_owned(),
)
},
|policy| policy.keychain.clone(),
);
let mut policy = existing.unwrap_or(config::ConfiguredAccessPolicy {
keychain: selector,
mode: keychain::AccessMode::Hybrid,
default: keychain::AccessDefault::Prompt,
trust_apps: Vec::new(),
trust_requirements: Vec::new(),
});
if let Some(mode) = mode {
policy.mode = (*mode).into();
}
if let Some(default) = default {
policy.default = (*default).into();
}
if *clear_trust {
policy.trust_apps.clear();
policy.trust_requirements.clear();
} else if !trust_apps.is_empty() || !trust_requirements.is_empty() {
policy.trust_apps.clone_from(trust_apps);
policy.trust_requirements = trust_requirements
.iter()
.map(|spec| requirement_source(spec))
.collect::<Result<_>>()?;
}
config.set_access_policy(policy.clone());
let saved = config.save()?;
if cli.is_json() {
println!(
"{}",
keychain::output::pretty(&serde_json::json!({
"ok": true,
"path": saved,
"keychain": path,
"policy": access_policy_json(&policy),
}))
);
} else {
println!("access policy saved: {}", saved.display());
report_access_policy(cli, &path, &policy);
}
Ok(())
}
AccessAction::Clear { keychain } => {
let path = config.resolve(keychain.as_deref())?;
let selector = config
.access_policy_for(&path)?
.map(|policy| policy.keychain.clone())
.ok_or_else(|| {
Error::other(format!(
"{} has no configured access policy",
path.display()
))
})?;
config.clear_access_policy(&selector);
let saved = config.save()?;
report(
cli,
&format!("access policy removed: {}", path.display()),
|| {
serde_json::json!({
"ok": true,
"path": saved,
"keychain": path,
"removed": true,
})
},
);
Ok(())
}
AccessAction::Apply {
password,
to: AccessProjection::Securityd,
keychain,
} => apply_access_policy(cli, &config, keychain.as_deref(), password),
AccessAction::Audit {
against: AccessProjection::Securityd,
keychain,
} => audit_access_policy(cli, &config, keychain.as_deref()),
}
}
fn report_access_policy(cli: &Cli, path: &Path, policy: &config::ConfiguredAccessPolicy) {
if cli.is_json() {
println!(
"{}",
keychain::output::pretty(&serde_json::json!({
"ok": true,
"keychain": path,
"policy": access_policy_json(policy),
}))
);
} else {
println!(
"{}",
keychain::output::field_list(&[
("keychain", path.display().to_string()),
("selector", policy.keychain.clone()),
("mode", config::access_mode_name(policy.mode).to_string()),
(
"default",
config::access_default_name(policy.default).to_string()
),
(
"trusted apps",
(policy.trust_apps.len() + policy.trust_requirements.len()).to_string()
),
])
);
for path in &policy.trust_apps {
println!("trust-app: {}", path.display());
}
for source in &policy.trust_requirements {
println!(
"trust-requirement: {}={}",
source.application.display(),
source.file.display()
);
}
}
}
fn access_policy_json(policy: &config::ConfiguredAccessPolicy) -> serde_json::Value {
serde_json::json!({
"selector": policy.keychain,
"mode": config::access_mode_name(policy.mode),
"default": config::access_default_name(policy.default),
"trust_apps": policy.trust_apps,
"trust_requirements": policy.trust_requirements.iter().map(|source| {
serde_json::json!({
"application": source.application,
"file": source.file,
})
}).collect::<Vec<_>>(),
})
}
fn requirement_source(spec: &str) -> Result<config::RequirementSource> {
let (application, file) = spec
.split_once('=')
.ok_or_else(|| Error::other(format!("expected PATH=FILE, got {spec:?}")))?;
if application.is_empty() || file.is_empty() {
return Err(Error::other(format!("expected PATH=FILE, got {spec:?}")));
}
Ok(config::RequirementSource {
application: PathBuf::from(application),
file: PathBuf::from(file),
})
}
fn resolved_access_policy(
config: &config::Config,
path: &Path,
) -> Result<Option<keychain::AccessPolicy>> {
config
.access_policy_for(path)?
.map(|policy| {
Ok(keychain::AccessPolicy {
mode: policy.mode,
default: policy.default,
trusted_applications: configured_trusted_applications(policy)?,
})
})
.transpose()
}
fn configured_trusted_applications(
policy: &config::ConfiguredAccessPolicy,
) -> Result<Vec<keychain::TrustedApplication>> {
let mut applications =
Vec::with_capacity(policy.trust_apps.len() + policy.trust_requirements.len());
for path in &policy.trust_apps {
applications.push(keychain::requirement::for_application(path)?);
}
for source in &policy.trust_requirements {
let blob =
std::fs::read(&source.file).map_err(|error| Error::reading(&source.file, error))?;
applications.push(keychain::requirement::from_blob(
source.application.to_string_lossy(),
blob,
)?);
}
Ok(applications)
}
fn apply_access_policy(
cli: &Cli,
config: &config::Config,
keychain: Option<&Path>,
password: &PasswordSource,
) -> Result<()> {
let path = config.resolve(keychain)?;
let policy = resolved_access_policy(config, &path)?.ok_or_else(|| {
Error::other(format!(
"{} has no configured access policy",
path.display()
))
})?;
if policy.default == keychain::AccessDefault::Deny {
return Err(Error::other(
"securityd ACLs cannot represent an unconditional deny policy",
));
}
if policy.default == keychain::AccessDefault::Prompt && policy.trusted_applications.is_empty() {
return Err(Error::other(
"a prompt policy needs at least one trusted application before it can be projected to securityd",
));
}
let mut file = KeychainFile::open(&path)?;
let password = password.resolve(false)?;
file.unlock(password.as_bytes())?;
let password_items: Vec<_> = file
.items()
.iter()
.map(|item| (item.record_type, item.number()))
.collect();
let private_keys: Vec<_> = file
.records_of_type(RecordType::PRIVATE_KEY)
.iter()
.map(|record| record.number)
.collect();
for (record_type, number) in &password_items {
file.set_item_trust(*record_type, *number, policy.native_trusted_applications())?;
}
for number in &private_keys {
file.set_private_key_trust(*number, policy.native_trusted_applications())?;
}
file.save(&path)?;
let count = password_items.len() + private_keys.len();
report(
cli,
&format!("applied access policy to {count} item(s)"),
|| {
serde_json::json!({
"ok": true,
"keychain": path,
"target": "securityd",
"items_updated": count,
})
},
);
Ok(())
}
fn audit_access_policy(cli: &Cli, config: &config::Config, keychain: Option<&Path>) -> Result<()> {
let path = config.resolve(keychain)?;
let policy = resolved_access_policy(config, &path)?.ok_or_else(|| {
Error::other(format!(
"{} has no configured access policy",
path.display()
))
})?;
let file = KeychainFile::open(&path)?;
let expected = policy.native_trusted_applications();
let mut total = 0usize;
let mut matching = 0usize;
let mut unknown = 0usize;
for item in file.items() {
total += 1;
match file.item_trusted_applications(item.record_type, item.number())? {
Some(actual) if actual == expected => matching += 1,
Some(_) => {}
None => unknown += 1,
}
}
for record in file.records_of_type(RecordType::PRIVATE_KEY) {
let number = record.number;
total += 1;
match file.private_key_trusted_applications(number)? {
Some(actual) if actual == expected => matching += 1,
Some(_) => {}
None => unknown += 1,
}
}
let ok = matching == total;
if cli.is_json() {
println!(
"{}",
keychain::output::pretty(&serde_json::json!({
"ok": ok,
"keychain": path,
"against": "securityd",
"items": total,
"matching": matching,
"mismatched": total - matching - unknown,
"unknown": unknown,
}))
);
} else {
println!(
"{}",
keychain::output::field_list(&[
("keychain", path.display().to_string()),
("items", total.to_string()),
("matching", matching.to_string()),
("mismatched", (total - matching - unknown).to_string()),
("unknown", unknown.to_string()),
])
);
}
if ok {
Ok(())
} else {
Err(Error::other(
"native item ACLs do not match the keychain policy",
))
}
}
fn authorize_secret_access(cli: &Cli, keychain: &Path, operation: &str) -> Result<()> {
let config = config::Config::load()?;
let Some(policy) = config.access_policy_for(keychain)? else {
return Ok(());
};
let decision = keychain::AccessPolicy {
mode: policy.mode,
default: policy.default,
trusted_applications: Vec::new(),
}
.direct_decision();
match decision {
keychain::AccessDecision::Allow => Ok(()),
keychain::AccessDecision::Deny => Err(Error::other(format!(
"access policy denies permission to {operation} from {}",
keychain.display()
))),
keychain::AccessDecision::Prompt if !cli.interactive => Err(Error::other(format!(
"access policy requires confirmation to {operation}; rerun with --interactive"
))),
keychain::AccessDecision::Prompt => prompt_access(keychain, operation),
}
}
fn prompt_access(keychain: &Path, operation: &str) -> Result<()> {
let mut tty = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open("/dev/tty")
.map_err(|error| {
Error::io(
"access confirmation requires an interactive terminal",
error,
)
})?;
write!(
tty,
"Allow kc to {operation} from {}? [y/N] ",
keychain.display()
)
.and_then(|()| tty.flush())
.map_err(|error| Error::io("could not write the access confirmation", error))?;
let mut answer = String::new();
BufReader::new(tty)
.read_line(&mut answer)
.map_err(|error| Error::io("could not read the access confirmation", error))?;
if matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes") {
Ok(())
} else {
Err(Error::other("access was not approved"))
}
}
fn trusted_applications_for_write(
keychain: &Path,
paths: &[PathBuf],
requirements: &[String],
) -> Result<Vec<keychain::TrustedApplication>> {
if !paths.is_empty() || !requirements.is_empty() {
return trusted_applications(paths, requirements);
}
let config = config::Config::load()?;
let Some(policy) = resolved_access_policy(&config, keychain)? else {
return Ok(Vec::new());
};
if policy.mode.projects_native() {
Ok(policy.trusted_applications)
} else {
Ok(Vec::new())
}
}
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 keychain = resolve_keychain(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_for_write(
&keychain,
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 keychain = resolve_keychain(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_for_write(
&keychain,
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 keychain = resolve_keychain(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_for_write(
&keychain,
trust_apps,
trust_requirements,
)?,
..NewItem::default()
};
add(
cli,
&keychain,
password,
RecordType::APPLESHARE_PASSWORD,
item,
secret,
)
}
AddKind::Identity {
password,
certificate,
key,
pkcs12,
pkcs12_password,
label,
trust_apps,
trust_requirements,
keychain,
} => {
let keychain = resolve_keychain(keychain)?;
let trusted =
trusted_applications_for_write(&keychain, trust_apps, trust_requirements)?;
let identity = if let Some(path) = pkcs12 {
identity_from_file(path, pkcs12_password, label.clone(), trusted)?
} else {
if pkcs12_password.password.is_some()
|| pkcs12_password.password_env.is_some()
|| pkcs12_password.password_file.is_some()
{
return Err(Error::other("a PKCS#12 password source requires --pkcs12"));
}
let certificate = certificate
.as_ref()
.expect("clap requires --cert when --pkcs12 is absent");
let key = key
.as_ref()
.expect("clap requires --key when --pkcs12 is absent");
NewIdentity {
certificate: read_der(certificate, keychain::der::PEM_CERTIFICATE)?,
private_key: read_der(key, keychain::der::PEM_PRIVATE_KEY)?,
label: label.clone(),
trusted_applications: trusted,
}
};
add_identity(cli, &keychain, password, &identity)
}
}
}
fn import_command(cli: &Cli, kind: &ImportKind) -> Result<()> {
match kind {
ImportKind::Identity {
password,
pkcs12_password,
label,
trust_apps,
trust_requirements,
input,
keychain,
} => {
let keychain = resolve_keychain(keychain)?;
let trusted =
trusted_applications_for_write(&keychain, trust_apps, trust_requirements)?;
let identity = identity_from_file(input, pkcs12_password, label.clone(), trusted)?;
add_identity(cli, &keychain, password, &identity)
}
}
}
fn identity_from_file(
path: &Path,
password: &Pkcs12PasswordSource,
label: Option<String>,
trusted_applications: Vec<keychain::acl::TrustedApplication>,
) -> Result<NewIdentity> {
let data = std::fs::read(path).map_err(|source| Error::reading(path, source))?;
let container_password = if keychain::is_combined_pem(&data) {
None
} else {
Some(password.resolve()?)
};
let bundle = keychain::decode_identity(&data, container_password.as_deref())?;
Ok(NewIdentity {
certificate: bundle.certificate,
private_key: bundle.private_key,
label: label.or(bundle.friendly_name),
trusted_applications,
})
}
fn find_command(cli: &Cli, kind: &FindKind) -> Result<()> {
match kind {
FindKind::Generic {
password,
account,
service,
generic,
label,
kind,
comment,
attributes,
secret_only,
keychain,
} => {
let keychain = resolve_keychain(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 keychain = resolve_keychain(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 keychain = resolve_keychain(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, &resolve_keychain(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!(
"{}",
keychain::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!(
"{}",
keychain::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, label: &str) -> Result<Vec<u8>> {
let bytes = std::fs::read(path)
.map_err(|source| Error::io(format!("could not read {}", path.display()), source))?;
keychain::der::pem_block(&bytes, label)
}
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) => {
authorize_secret_access(cli, keychain, "read a secret")?;
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!(
"{}",
keychain::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!("{}", keychain::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!(
"{}",
keychain::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!("{}", keychain::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!("{}", keychain::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!(
"{}",
keychain::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<()> {
authorize_secret_access(cli, keychain, "verify item secrets")?;
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!(
"{}",
keychain::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!(
"{}",
keychain::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: &keychain::Value) -> String {
if keychain::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!("{}", keychain::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<keychain::acl::TrustedApplication>> {
let mut applications = Vec::with_capacity(paths.len() + requirements.len());
for path in paths {
applications.push(keychain::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(keychain::requirement::from_blob(path, blob)?);
}
Ok(applications)
}
fn set_command(
cli: &Cli,
keychain: &Path,
password: &PasswordSource,
selector: &Selector,
secret: Option<&str>,
changes: &ItemChanges,
) -> Result<()> {
if selector.is_empty() {
return Err(Error::other(
"name the item to change, for example with -a and -s",
));
}
if changes.is_empty() && secret.is_none() {
return Err(Error::other(
"nothing to change; pass -w for the secret or one of the --set-* flags",
));
}
let mut file = KeychainFile::open(keychain)?;
if secret.is_some() {
let password = password.resolve(false)?;
file.unlock(password.as_bytes())?;
}
let (record_type, number, label) = {
let item = file.find_one(&selector.to_query()?)?;
(item.record_type, item.number(), item.label())
};
let secret = match secret {
None => None,
Some("") => Some(item_secret(&None)?),
Some(given) => Some(given.to_string()),
};
file.update_item(
record_type,
number,
changes,
secret.as_ref().map(|secret| secret.as_bytes()),
&now_timestamp(),
)?;
file.save(keychain)?;
report(cli, "updated", || {
serde_json::json!({
"ok": true,
"class": record_type.short_name(),
"record": number,
"label": label,
"secret_changed": secret.is_some(),
})
});
Ok(())
}
fn rm_command(cli: &Cli, kind: &RmKind) -> Result<()> {
match kind {
RmKind::Item {
password,
selector,
all,
keychain,
} => {
let keychain = resolve_keychain(keychain)?;
if selector.is_empty() {
return Err(Error::other(
"name the item to delete, for example with -a and -s",
));
}
let mut file = KeychainFile::open(&keychain)?;
if let Some(password) = password.resolve_optional(false)? {
file.unlock(password.as_bytes())?;
}
let targets: Vec<(RecordType, u32, Option<String>)> = file
.find(&selector.to_query()?)
.iter()
.map(|item| (item.record_type, item.number(), item.label()))
.collect();
match targets.len() {
0 => return Err(Error::NoSuchItem),
1 => {}
count if !*all => {
return Err(Error::other(format!(
"{count} items match; narrow the query or pass --all"
)));
}
_ => {}
}
let mut deleted = Vec::new();
for (record_type, number, label) in &targets {
let records = file.delete_item(*record_type, *number)?;
deleted.push(serde_json::json!({
"class": record_type.short_name(),
"record": number,
"label": label,
"records_removed": records,
}));
}
file.save(&keychain)?;
report(
cli,
&format!("deleted {} item(s)", deleted.len()),
|| serde_json::json!({ "ok": true, "deleted": deleted }),
);
Ok(())
}
RmKind::Identity {
password,
label,
hash,
keychain,
} => rm_identity(
cli,
&resolve_keychain(keychain)?,
password,
label.as_deref(),
hash.as_deref(),
true,
),
RmKind::Certificate {
password,
label,
hash,
keychain,
} => rm_identity(
cli,
&resolve_keychain(keychain)?,
password,
label.as_deref(),
hash.as_deref(),
false,
),
}
}
fn rm_identity(
cli: &Cli,
keychain: &Path,
password: &PasswordSource,
label: Option<&str>,
hash: Option<&str>,
with_key: bool,
) -> Result<()> {
let mut file = KeychainFile::open(keychain)?;
if let Some(password) = password.resolve_optional(false)? {
file.unlock(password.as_bytes())?;
}
let hash = identity_hash(&file, label, hash)?;
let removed = if with_key {
file.delete_identity(&hash)?
} else {
file.delete_certificate(&hash)?
};
file.save(keychain)?;
report(cli, &format!("deleted {removed} record(s)"), || {
serde_json::json!({
"ok": true,
"public_key_hash": hex::encode(&hash),
"records_removed": removed,
"private_key_removed": with_key,
})
});
Ok(())
}
fn identity_hash(file: &KeychainFile, label: Option<&str>, hash: Option<&str>) -> Result<Vec<u8>> {
if let Some(hash) = hash {
return hex::decode(hash)
.map_err(|error| Error::other(format!("{hash:?} is not hex: {error}")));
}
let identities = file.identities();
let matches: Vec<_> = identities
.iter()
.filter(|identity| match label {
None => true,
Some(wanted) => identity.label.as_deref() == Some(wanted),
})
.collect();
match matches.len() {
0 => Err(Error::NoSuchItem),
1 => Ok(matches[0].public_key_hash.clone()),
count => Err(Error::other(format!(
"{count} identities match; narrow it with -l or --hash"
))),
}
}
fn trust_command(
cli: &Cli,
keychain: &Path,
password: &PasswordSource,
selector: &Selector,
trusted: &[keychain::acl::TrustedApplication],
) -> Result<()> {
if selector.is_empty() {
return Err(Error::other("name the item, for example with -a and -s"));
}
let mut file = KeychainFile::open(keychain)?;
let password = password.resolve(false)?;
file.unlock(password.as_bytes())?;
let (record_type, number, label) = {
let item = file.find_one(&selector.to_query()?)?;
(item.record_type, item.number(), item.label())
};
file.set_item_trust(record_type, number, trusted)?;
file.save(keychain)?;
report(cli, "access updated", || {
serde_json::json!({
"ok": true,
"class": record_type.short_name(),
"record": number,
"label": label,
"trusted_applications": trusted
.iter()
.map(|application| application.path.clone())
.collect::<Vec<_>>(),
})
});
Ok(())
}
enum DestinationPassword<'a> {
Source(&'a PasswordSource),
Prompt,
}
fn copy_command(
cli: &Cli,
source: &Path,
destination: &Path,
from: &PasswordSource,
to: DestinationPassword<'_>,
selector: &Selector,
trusted: &[keychain::acl::TrustedApplication],
) -> Result<()> {
if selector.is_empty() {
return Err(Error::other("name the item, for example with -a and -s"));
}
let mut origin = KeychainFile::open(source)?;
authorize_secret_access(cli, source, "copy a secret")?;
let source_password = from.resolve(false)?;
origin.unlock(source_password.as_bytes())?;
let item = origin.find_one(&selector.to_query()?)?;
let record_type = item.record_type;
let secret = origin.secret(&item)?;
let new = NewItem {
label: item.label(),
account: item.account(),
service: item.service(),
generic: item
.attribute("gena")
.and_then(|value| value.as_bytes())
.map(<[u8]>::to_vec),
server: item.server(),
security_domain: item.text("sdmn"),
path: item.path(),
port: item.port(),
protocol: four_char_code(item.display_attribute("ptcl").as_deref())?,
auth_type: four_char_code(item.text("atyp").as_deref())?,
volume: item.volume(),
address: item.address(),
signature: item.signature(),
description: item.text("desc"),
comment: item.text("icmt"),
trusted_applications: trusted.to_vec(),
};
let summary = serde_json::json!({
"ok": true,
"class": record_type.short_name(),
"label": new.label,
"account": new.account,
"destination": destination,
});
let mut target = KeychainFile::open(destination)?;
let destination_password = match to {
DestinationPassword::Prompt => {
rpassword::prompt_password("Destination keychain password: ")
.map_err(|error| Error::io("could not read the destination password", error))?
}
DestinationPassword::Source(source) if source.is_explicit() => source.resolve(false)?,
DestinationPassword::Source(_) => source_password.clone(),
};
target.unlock(destination_password.as_bytes())?;
target.add_password(record_type, &new, secret.as_slice(), &now_timestamp())?;
target.save(destination)?;
report(cli, "copied", || summary);
Ok(())
}
fn export_command(cli: &Cli, kind: &ExportKind) -> Result<()> {
match kind {
ExportKind::Certificate {
password,
label,
der,
out,
keychain,
} => {
let keychain = resolve_keychain(keychain)?;
let mut file = KeychainFile::open(&keychain)?;
if let Some(password) = password.resolve_optional(false)? {
file.unlock(password.as_bytes())?;
}
let identity = one_identity(&file, label.as_deref())?;
let bytes = if *der {
identity.certificate.clone()
} else {
keychain::der::to_pem(keychain::der::PEM_CERTIFICATE, &identity.certificate)
.into_bytes()
};
write_export(cli, out.as_deref(), &bytes, "certificate", &identity.label)
}
ExportKind::Key {
password,
label,
der,
out,
keychain,
} => {
let keychain = resolve_keychain(keychain)?;
let mut file = KeychainFile::open(&keychain)?;
authorize_secret_access(cli, &keychain, "export a private key")?;
let password = password.resolve(false)?;
file.unlock(password.as_bytes())?;
let identity = one_identity(&file, label.as_deref())?;
let record = identity
.private_key_record
.ok_or_else(|| Error::other("that identity has no private key in this keychain"))?;
let key = file.private_key_pkcs8(record)?;
let bytes = if *der {
key.as_slice().to_vec()
} else {
keychain::der::to_pem(keychain::der::PEM_PRIVATE_KEY, key.as_slice()).into_bytes()
};
write_export(cli, out.as_deref(), &bytes, "private key", &identity.label)
}
ExportKind::Identity {
password,
label,
out,
pkcs12,
pem,
pkcs12_password,
keychain,
} => {
let keychain = resolve_keychain(keychain)?;
let mut file = KeychainFile::open(&keychain)?;
authorize_secret_access(cli, &keychain, "export an identity")?;
let password = password.resolve(false)?;
file.unlock(password.as_bytes())?;
let identity = one_identity(&file, label.as_deref())?;
let record = identity
.private_key_record
.ok_or_else(|| Error::other("that identity has no private key in this keychain"))?;
let key = file.private_key_pkcs8(record)?;
let bytes = if *pkcs12 {
let bundle = keychain::pkcs12::Identity {
certificate: identity.certificate.clone(),
private_key: key.as_slice().to_vec(),
friendly_name: identity.label.clone(),
};
let der = keychain::pkcs12::encode(&bundle, &pkcs12_password.resolve()?)?;
if *pem {
keychain::der::to_pem(keychain::der::PEM_PKCS12, &der).into_bytes()
} else {
der
}
} else {
if pkcs12_password.password.is_some()
|| pkcs12_password.password_env.is_some()
|| pkcs12_password.password_file.is_some()
{
return Err(Error::other("a PKCS#12 password source requires --pkcs12"));
}
let mut pem =
keychain::der::to_pem(keychain::der::PEM_CERTIFICATE, &identity.certificate)
.into_bytes();
pem.extend_from_slice(
keychain::der::to_pem(keychain::der::PEM_PRIVATE_KEY, key.as_slice())
.as_bytes(),
);
pem
};
write_export(cli, out.as_deref(), &bytes, "identity", &identity.label)
}
}
}
fn one_identity(
file: &KeychainFile,
label: Option<&str>,
) -> Result<keychain::edit::StoredIdentity> {
let identities = file.identities();
let mut matches: Vec<_> = identities
.into_iter()
.filter(|identity| match label {
None => true,
Some(wanted) => identity.label.as_deref() == Some(wanted),
})
.collect();
match matches.len() {
0 => Err(Error::NoSuchItem),
1 => Ok(matches.remove(0)),
count => Err(Error::other(format!(
"{count} certificates match; narrow it with -l"
))),
}
}
fn write_export(
cli: &Cli,
out: Option<&Path>,
bytes: &[u8],
what: &str,
label: &Option<String>,
) -> Result<()> {
match out {
Some(path) => {
use std::io::Write as _;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.map_err(|source| {
Error::io(format!("could not create {}", path.display()), source)
})?;
std::fs::set_permissions(path, PermissionsExt::from_mode(0o600)).map_err(|source| {
Error::io(format!("could not secure {}", path.display()), source)
})?;
file.write_all(bytes).map_err(|source| {
Error::io(format!("could not write {}", path.display()), source)
})?;
report(
cli,
&format!("wrote {} to {}", what, path.display()),
|| {
serde_json::json!({
"ok": true,
"wrote": path,
"what": what,
"label": label,
"bytes": bytes.len(),
})
},
);
}
None => {
std::io::stdout()
.write_all(bytes)
.map_err(|source| Error::io("could not write the export", source))?;
}
}
Ok(())
}
fn passwd_command(
cli: &Cli,
keychain: &Path,
old: &PasswordSource,
new: &PasswordSource,
) -> Result<()> {
let mut file = KeychainFile::open(keychain)?;
let old_password = old.resolve(false)?;
file.unlock(old_password.as_bytes())?;
let new_password = if new.is_explicit() {
new.resolve(true)?
} else if std::io::stdin().is_terminal() {
let entered = rpassword::prompt_password("New keychain password: ")
.map_err(|source| Error::io("could not read the password", source))?;
let again = rpassword::prompt_password("Confirm: ")
.map_err(|source| Error::io("could not read the password", source))?;
if entered != again {
return Err(Error::other("the two entries did not match"));
}
entered
} else {
read_password_line()?
};
file.change_password(old_password.as_bytes(), new_password.as_bytes())?;
file.save(keychain)?;
let generated = new.password_gen.as_ref().map(|_| new_password.as_str());
report(
cli,
&generated.map_or_else(
|| "password changed".to_string(),
|value| format!("password changed\npassword: {value}"),
),
|| {
serde_json::json!({
"ok": true,
"keychain": keychain,
"generated_password": generated,
})
},
);
Ok(())
}
fn settings_command(
cli: &Cli,
keychain: &Path,
password: &PasswordSource,
idle_timeout: Option<u32>,
lock_on_sleep: Option<bool>,
) -> Result<()> {
let mut file = KeychainFile::open(keychain)?;
let changing = idle_timeout.is_some() || lock_on_sleep.is_some();
if changing {
let password = password.resolve(false)?;
file.unlock(password.as_bytes())?;
}
let mut settings = file.settings()?;
if changing {
if let Some(timeout) = idle_timeout {
settings.idle_timeout = timeout;
}
if let Some(lock) = lock_on_sleep {
settings.lock_on_sleep = lock;
}
file.set_settings(&settings)?;
file.save(keychain)?;
}
if cli.is_json() {
println!(
"{}",
keychain::output::pretty(&serde_json::json!({
"ok": true,
"idle_timeout": settings.idle_timeout,
"never_times_out": settings.never_times_out(),
"lock_on_sleep": settings.lock_on_sleep,
"changed": changing,
}))
);
return Ok(());
}
println!(
"{}",
keychain::output::field_list(&[
(
"idle timeout",
if settings.never_times_out() {
"never".to_string()
} else {
format!("{}s", settings.idle_timeout)
}
),
("lock on sleep", settings.lock_on_sleep.to_string()),
])
);
Ok(())
}
fn attribute_assignments(specs: &[String]) -> Result<Vec<(String, keychain::Value)>> {
specs
.iter()
.map(|spec| {
let (name, value) = spec
.split_once('=')
.ok_or_else(|| Error::other(format!("expected NAME=VALUE, got {spec:?}")))?;
Ok((
name.to_string(),
keychain::Value::Blob(value.as_bytes().to_vec()),
))
})
.collect()
}
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))
}
#[cfg(test)]
mod cli_compatibility_tests {
use super::*;
#[test]
fn spaced_optional_password_values_remain_accepted() {
let cli =
Cli::try_parse_from(["kc", "passwd", "--new-password", "new-value", "login"]).unwrap();
let Command::Passwd { new_password, .. } = cli.command else {
panic!("parsed the wrong command");
};
assert_eq!(new_password, Some(Some("new-value".to_string())));
let cli = Cli::try_parse_from([
"kc",
"export",
"identity",
"--pkcs12",
"--pkcs12-password",
"bundle-value",
"login",
])
.unwrap();
let Command::Export {
kind: ExportKind::Identity {
pkcs12_password, ..
},
} = cli.command
else {
panic!("parsed the wrong command");
};
assert_eq!(
pkcs12_password.password,
Some(Some("bundle-value".to_string()))
);
}
#[test]
fn destination_password_can_still_be_prompt_only() {
let cli = Cli::try_parse_from([
"kc",
"cp",
"--to-password",
"-a",
"alice",
"source",
"destination",
])
.unwrap();
let Command::Cp { to_password, .. } = cli.command else {
panic!("parsed the wrong command");
};
assert_eq!(to_password, Some(None));
}
}