This is quite a large refactor that completely removes the previous crate::Identity struct in favour of the new crate::identity::Complete, which is almost a drop-in replacement with some extra features. To accomplish this, identity::Complete has been re-worked to support all use-cases, including storage of identities both locally & remotely. Also, some implementations on identity::Complete have been re-worked to allow for greater readability, and the previously removed key_path
field has been re-implemented.
4OJWMSOWWNT5N4W4FDMKBZB5UARCLGV3SRZVKGR4EFAYFUMUHM7AC
FVQYZQFL7WHSC3UUPJ4IWTP7SKTDQ4K6K5HY4EDK3JKXG3CQNZEAC
4KJ45IJLTIE35KQZUSFMFS67RNENG4P2FZMKMULJLGGYMKJUVRSQC
DWSAYGVEOR4D2EKIICEZUWCRGJTUXQQLOUWMYIFV7XN62K44F4FAC
LGEJSLTYI7Y2CYC3AN6ECMT3D3MTWCAKZPVQEG5MPM2OBW5FQ46AC
SXEYMYF7P4RZMZ46WPL4IZUTSQ2ATBWYZX7QNVMS3SGOYXYOHAGQC
A3RM526Y7LUXNYW4TL56YKQ5GVOK2R5D7JJVTSQ6TT5MEXIR6YAAC
FBXYP7QM7SG6P2JDJVQPPCRKJE3GVYXNQ5GVV4GRDUNG6Q4ZRDJQC
EEBKW7VTILH6AGGV57ZIJ3DJGYHDSYBWGU3C7Q4WWAKSVNUGIYMQC
LJFJEX43HDS33O5HCRXH7AR3GTQZDHNWHEQBOERDNPNXR3B3XZ3QC
SLJ3OHD4F6GJGZ3SV2D7DMR3PXYHPSI64X77KZ3RJ24EGEX6ZNQAC
SMMBFECLGSUKRZW5YPOQPOQCOY2CH2OTZXBSZ3KG2N3J3HQZ5PSAC
NAUECZW353R5RHT4GGQJIEZPA5EYRGQYTSP7IJNBJS3CXBSTNJDQC
OU6JOR3CDZTH2H3NTGMV3WDIAWPD3VEJI7JRY3VJ7LPDR3QOA52QC
#[derive(Debug)]
pub(super) struct PotentialPublicKey {
pub key: PublicKey,
pub generated_by: GeneratedBy,
}
#[derive(Debug)]
pub(super) enum GeneratedBy {
SecretKey,
PublicKey,
Identity,
}
impl PotentialPublicKey {
/// Loads all given public keys into an array.
///
/// # Arguments
/// * `public_key` - The public key to validate
/// * `identity` - The identity to validate
/// * `secret_key` - The secret key to validate
/// * `password` - The password used to decrypt the secret key
/// * `name` - The name of the identity. This is encoded on-disk as identities/`<NAME>`
pub(super) fn load_public_keys(
public_key: Option<PublicKey>,
identity: Option<Identity>,
secret_key: &SecretKey,
password: Option<String>,
name: &str,
) -> Result<Vec<Self>, anyhow::Error> {
// Construct a vec of all potential (not yet validated) public keys, and make sure they all match
let mut public_keys: Vec<Self> = vec![];
let user_password = get_valid_password(secret_key, password, name)?;
public_keys.push(Self {
key: secret_key.load(user_password.as_deref())?.public_key(),
generated_by: GeneratedBy::SecretKey,
});
if let Some(key) = public_key {
public_keys.push(Self {
key,
generated_by: GeneratedBy::PublicKey,
});
}
if let Some(user_identity) = identity {
public_keys.push(Self {
key: user_identity.public_key,
generated_by: GeneratedBy::Identity,
});
}
Ok(public_keys)
}
}
let public_keys = PotentialPublicKey::load_public_keys(
None,
Some(self.identity.clone()),
&self.secret_key,
self.password.clone(),
&self.name,
)?;
if public_keys.len() >= 2 {
let mut keys_iter = public_keys.iter();
let first_key = keys_iter.next().unwrap();
let public_key = &self.public_key;
let decryped_public_key = self.decrypt()?.0.public_key();
let mut different_keys = vec![first_key];
for key in keys_iter {
if !different_keys.iter().any(|k| k.key == key.key) {
different_keys.push(key);
}
}
if different_keys.len() != 1 {
let mut stderr = std::io::stderr();
writeln!(stderr, "{MISMATCHED_KEYS_MESSAGE}")?;
writeln!(stderr, "Got the following public key signatures:")?;
for key in different_keys {
let source = match key.generated_by {
GeneratedBy::Identity => "Identity",
GeneratedBy::PublicKey => "Public key",
GeneratedBy::SecretKey => "Secret key",
};
writeln!(stderr, "{} ({source})", key.key.key)?;
}
if public_key.key != decryped_public_key.key {
let mut stderr = std::io::stderr();
writeln!(stderr, "{MISMATCHED_KEYS_MESSAGE}")?;
writeln!(stderr, "Got the following public key signatures:")?;
writeln!(stderr, "Plaintext public key: {public_key:#?}")?;
writeln!(stderr, "Decrypted public key: {decryped_public_key:#?}")?;
password,
secret_key,
Identity {
login: author.login,
name: author.full_name,
email: author.email,
origin,
last_modified: chrono::DateTime::timestamp(&chrono::offset::Utc::now())
.try_into()
.context("Failed to create UNIX timestamp")?,
public_key,
},
true,
config,
public_key,
Some(super::Credentials::from(secret_key)),
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Config {
#[serde(flatten)]
pub author: Author,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key_path: Option<PathBuf>,
}
impl Default for Config {
fn default() -> Self {
Self {
key_path: None,
author: Author::default(),
}
}
}
impl From<Author> for Config {
fn from(author: Author) -> Self {
Self {
key_path: None,
author,
}
}
}
pub struct Credentials {
secret_key: SecretKey,
password: OnceCell<String>,
}
impl Credentials {
pub fn new(secret_key: SecretKey, password: Option<String>) -> Self {
Self {
secret_key,
password: if let Some(pw) = password {
OnceCell::from(pw)
} else {
OnceCell::new()
},
}
}
}
impl From<SecretKey> for Credentials {
fn from(secret_key: SecretKey) -> Self {
Self {
secret_key,
password: OnceCell::new(),
}
}
}
impl Credentials {
pub fn decrypt(&mut self, name: &str) -> Result<(SKey, Option<String>), anyhow::Error> {
if self.secret_key.encryption.is_none() {
// Don't mind what the given password is, the secret key has no encryption
// Make sure to revoke the password
self.password.take();
Ok((self.secret_key.load(None)?, None))
} else if let Ok(key) = self
.secret_key
.load(self.password.get().map(String::as_str))
{
// The password matches secret key, no extra work needed
Ok((key, self.password.get().map(|x| x.to_owned())))
} else {
// Password does not match secret key
let mut stderr = std::io::stderr();
let mut password_attempt = String::new();
// Try a password stored in the keychain
if let Ok(password) = keyring::Entry::new("pijul", name).get_password() {
password_attempt = password;
}
// Re-prompt as long as the password doesn't work
while self.secret_key.load(Some(&password_attempt)).is_err() {
writeln!(stderr, "Password does not match secret key")?;
password_attempt = Password::with_theme(config::load_theme()?.as_ref())
.with_prompt("Password for secret key")
.allow_empty_password(true)
.interact()?;
}
// Update the password
keyring::Entry::new("pijul", name).set_password(&password_attempt)?;
self.password.set(password_attempt.clone()).unwrap();
Ok((
self.secret_key.load(Some(&password_attempt))?,
Some(password_attempt),
))
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub password: Option<String>,
pub secret_key: SecretKey,
pub identity: Identity,
pub link_remote: bool,
#[serde(flatten)]
pub config: Config,
pub last_modified: chrono::DateTime<chrono::Utc>,
pub public_key: PublicKey,
#[serde(skip)]
credentials: Option<Credentials>,
/// * `password` - Password used to decrypt the secret key
/// * `secret_key` - The corresponding secret key
/// * `identity` - Information about the author including name, email, login
/// * `link_remote` - If this identity should be linked with a remote (e.g. the Nest)
pub const fn new(
/// * `config` - User configuration including author details & SSH key
/// * `public_key` - The user's public key
/// * `credentials` - The user's secret data including secret key & password
pub fn new(
None,
secret_key.save(None),
Identity {
login: author.login,
name: author.full_name,
origin: String::from("ssh.pijul.com"),
email: author.email,
last_modified: chrono::DateTime::timestamp(&chrono::offset::Utc::now())
.try_into()?,
public_key,
},
true,
Config::from(author),
public_key,
Some(Credentials::from(secret_key.save(None))),
}
/// Returns the secret key, if one exists
pub fn secret_key(&self) -> Option<SecretKey> {
if let Some(credentials) = &self.credentials {
Some(credentials.secret_key.clone())
} else {
None
}
}
/// Strips the identity of any device-specific information, such as key path & identity name
/// Returns the stripped identity
pub fn as_portable(&self) -> Self {
Self {
name: String::new(),
last_modified: chrono::offset::Utc::now(),
config: Config {
key_path: None,
author: self.config.author.clone(),
},
public_key: self.public_key.clone(),
credentials: None,
}
}
/// Decrypts the user's secret key, prompting the user for password if necessary
/// Returns a tuple containing the decrypted key & the valid password
pub fn decrypt(&self) -> Result<(SKey, Option<String>), anyhow::Error> {
self.credentials.clone().unwrap().decrypt(&self.name)
}
fn change_password(&mut self) -> Result<(), anyhow::Error> {
let (decryped_key, _) = self.decrypt()?;
let user_password = Password::with_theme(config::load_theme()?.as_ref())
.with_prompt("New password")
.allow_empty_password(true)
.with_confirmation("Confirm password", "Password mismatch")
.interact()?;
let password = if user_password.is_empty() {
OnceCell::new()
} else {
// User has entered a password, add it to the keyring
keyring::Entry::new("pijul", &self.name).set_password(&user_password)?;
OnceCell::from(user_password)
};
// Update the key pair to match this new password
self.public_key = decryped_key.public_key();
self.credentials = Some(Credentials {
secret_key: decryped_key.save(password.get().map(String::as_str)),
password,
});
Ok(())
}
}
/// Loads a password, either from the keychain or from user input
///
/// # Arguments
/// * `name` - The name of the identity. This is encoded on-disk as identities/`<NAME>`
fn load_password(name: &str) -> Result<Option<String>, anyhow::Error> {
if let Ok(password) = keyring::Entry::new("pijul", name).get_password() {
if password.is_empty() {
return Ok(None);
}
return Ok(Some(password));
}
let pw = Password::with_theme(config::load_theme()?.as_ref())
.with_prompt("Password for secret key")
.allow_empty_password(true)
.interact()?;
if pw.is_empty() {
Ok(None)
} else {
Ok(Some(pw))
/// Validates a given password, prompting for user input if needed.
///
/// # Arguments
/// * `secret_key` - The secret key to decrypt
/// * `password` - The password to validate
/// * `name` - The name of the identity. This is encoded on-disk as identities/`<NAME>`
pub fn get_valid_password(
secret_key: &SecretKey,
password: Option<String>,
name: &str,
) -> Result<Option<String>, anyhow::Error> {
if secret_key.encryption.is_some() || secret_key.load(None).is_err() {
if secret_key.load(password.as_deref()).is_ok() {
Ok(password)
} else {
let mut user_password = load_password(name)?;
// If the user enters the wrong password, reprompt
let mut stderr = std::io::stderr();
while secret_key.load(user_password.as_deref()).is_err() {
writeln!(stderr, "Incorrect password! Please try again.")?;
user_password = load_password(name)?;
}
Ok(user_password)
}
} else {
Ok(None)
}
}
/// Decrypts the secret key associated with the given identity name. If the given password is invalid, the user may be prompted.
///
/// # Arguments
/// * `identity_name` - The name of the identity. This is encoded on-disk as identities/`<NAME>`
/// * `password` - The password used to attempt decryption
pub fn decrypt_secret_key(
identity_name: &str,
password: Option<String>,
) -> Result<(libpijul::key::SecretKey, libpijul::key::SKey), anyhow::Error> {
let secret_key = secret_key(identity_name)?;
let password = get_valid_password(&secret_key, password, identity_name)?;
let decrypted_key = secret_key.load(password.as_deref())?;
Ok((secret_key, decrypted_key))
}
let email: Option<String> = if user_email.is_empty() {
None
} else {
Some(user_email)
};
if Confirm::with_theme(config.as_ref())
.with_prompt(&format!(
"Would you like to change the default SSH key? (Current key: {})",
if let Some(path) = &self.config.key_path {
format!("{path:#?}")
} else {
String::from("none")
}
))
.default(false)
.interact()?
{
new_identity.prompt_ssh().await?;
}
if Confirm::with_theme(config.as_ref())
.with_prompt(&format!(
"Do you want to change the encryption? (Current status: {})",
self.credentials
.clone()
.unwrap()
.secret_key
.encryption
.map_or("not encrypted", |_| "encrypted")
))
.default(false)
.interact()?
{
new_identity.change_password()?;
}
// Update the expiry AFTER potential secret key reset
new_identity.prompt_expiry()?;
if Confirm::with_theme(config.as_ref())
.with_prompt("Do you want to link this identity to a remote?")
.default(true)
.interact()?
{
new_identity.prompt_remote(link_remote).await?;
}
new_identity.last_modified = chrono::offset::Utc::now();
Ok(new_identity)
}
async fn prompt_ssh(&mut self) -> Result<(), anyhow::Error> {
let mut ssh_agent = thrussh_keys::agent::client::AgentClient::connect_env().await?;
let identities = ssh_agent.request_identities().await?;
let ssh_dir = dirs_next::home_dir().unwrap().join(".ssh");
let selection = FuzzySelect::with_theme(config::load_theme()?.as_ref())
.with_prompt("Select key")
.items(
&identities
.iter()
.map(|id| {
format!(
"{}: {} ({})",
id.name(),
id.fingerprint(),
ssh_dir
.join(match id {
PublicKey::Ed25519(_) =>
thrussh_keys::key::ED25519.identity_file(),
PublicKey::RSA { ref hash, .. } => hash.name().identity_file(),
})
.display(),
)
})
.collect::<Vec<_>>(),
)
.default(0)
.interact()?;
self.config.key_path = Some(ssh_dir.join(match identities[selection] {
PublicKey::Ed25519(_) => thrussh_keys::key::ED25519.identity_file(),
PublicKey::RSA { ref hash, .. } => hash.name().identity_file(),
}));
Ok(())
}
async fn prompt_remote(&mut self, link_remote: bool) -> Result<(), IdentityCreateError> {
let config = config::load_theme()?;
self.config.author.user_name = Input::with_theme(config.as_ref())
.with_prompt("Remote username")
.default(whoami::username())
.with_initial_text(&self.config.author.user_name)
.interact_text()
.unwrap();
self.config.author.origin = Input::with_theme(config.as_ref())
.with_prompt("Remote URL")
.with_initial_text(&self.config.author.origin)
.default(String::from("ssh.pijul.com"))
.interact_text()
.unwrap();
// Prove the identity to the server
if link_remote
&& self
.prove(*NO_CERT_CHECK.get_or_init(|| false))
.await
.is_err()
{
return Err(IdentityCreateError::ProveFailed(self.name.clone()));
}
Ok(())
}
};
let link_remote: bool = Confirm::with_theme(config.as_ref())
.with_prompt("Do you want to link this identity to a remote?")
.default(self.link_remote)
.interact()?;
let user_name = if link_remote {
Input::with_theme(config.as_ref())
.with_prompt("Remote username")
.default(whoami::username())
.with_initial_text(self.identity.login)
.interact_text()?
} else {
String::new()
};
let remote_url = if link_remote {
Input::with_theme(config.as_ref())
.with_prompt("Remote URL")
.with_initial_text(if replace_current {
self.identity.origin
} else {
String::new()
})
.default(String::from("ssh.pijul.com"))
.interact_text()?
} else {
String::new()
let change_password = Confirm::with_theme(config.as_ref())
.with_prompt(&format!(
"Do you want to change the encryption? (Current status: {})",
self.secret_key
.encryption
.clone()
.map_or("not encrypted", |_| "encrypted")
))
.default(false)
.interact()?;
let password = if change_password {
let user_password = Password::with_theme(config.as_ref())
.with_prompt("Secret key password")
.allow_empty_password(true)
.with_confirmation("Confirm password", "Password mismatch")
.interact()?;
let pass = if user_password.is_empty() {
None
} else {
Some(user_password)
};
// Update the key pair to match this new password
let loaded_key = self.secret_key.clone().load(
get_valid_password(&self.secret_key, self.password, &identity_name)?.as_deref(),
)?;
self.identity.public_key = loaded_key.public_key();
self.secret_key = loaded_key.save(pass.as_deref());
pass
} else {
self.password
};
// Update the expiry AFTER potential secret key reset
self.identity.public_key.expires = expiry;
Ok(Self::new(
identity_name,
password,
self.secret_key,
Identity {
public_key: self.identity.public_key,
login: user_name,
name: Some(full_name),
origin: remote_url,
email,
last_modified: chrono::DateTime::timestamp(&chrono::offset::Utc::now())
.try_into()?,
},
link_remote,
))
Ok(())
// Only exchange keys with the server if the user agreed to do so
if link_remote
&& confirmed_identity.link_remote
&& confirmed_identity
.prove(*NO_CERT_CHECK.get_or_init(|| false))
.await
.is_err()
{
return Err(IdentityCreateError::ProveFailed(
confirmed_identity.name.clone(),
));
}
// If the user has entered a password, add it to the keyring
if let Some(password) = confirmed_identity.password.clone() {
Entry::new("pijul", &confirmed_identity.name)
.set_password(&password)
.context("Unable to write to keychain")?;
}
Ok(confirmed_identity)
Ok(())
// This is for backwards compatability with older versions
#[serde(alias = "name")]
pub login: String,
pub email: Option<String>,
pub full_name: Option<String>,
// Older versions called this 'name', but 'user_name' is more descriptive
#[serde(alias = "name", default, skip_serializing_if = "String::is_empty")]
pub user_name: String,
#[serde(alias = "full_name", default, skip_serializing_if = "String::is_empty")]
pub display_name: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub email: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub origin: String,
// This has been moved to identity::Config, but we should still be able to read the values
#[serde(default, skip_serializing)]
pub key_path: Option<PathBuf>,
}
impl Default for Author {
fn default() -> Self {
Self {
user_name: String::new(),
email: String::new(),
display_name: whoami::realname(),
origin: String::new(),
key_path: None,
}
}
}
use serde_derive::*;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Identity {
pub login: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub origin: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(default)]
pub last_modified: u64,
pub public_key: libpijul::key::PublicKey,
if let Some(ref name) = id.name {
if let Some(ref email) = id.email {
e.insert(format!("{} ({}) <{}>", name, id.login, email))
if id.config.author.display_name.is_empty() {
e.insert(id.config.author.user_name)
} else {
if id.config.author.email.is_empty() {
e.insert(format!(
"{} ({})",
id.config.author.display_name,
id.config.author.user_name
))
Ok(Complete {
name: identity_name.unwrap_or(default.name),
password: pw.clone(),
secret_key: secret_key.save(pw.as_deref()),
identity: Identity {
login: login.unwrap_or(default.identity.login),
name: display_name.or(default.identity.name),
origin: origin.unwrap_or(default.identity.origin),
email: email.or(default.identity.email),
last_modified: default.identity.last_modified,
public_key: secret_key.public_key(),
Ok(Complete::new(
identity_name.unwrap_or(default.name),
identity::Config {
key_path: None,
author: Author {
user_name: login.unwrap_or(default.config.author.user_name),
display_name: display_name.unwrap_or(default.config.author.display_name),
email: email.unwrap_or(default.config.author.email),
origin: origin.unwrap_or(default.config.author.origin),
key_path: None,
},
tree.add_empty_child(format!("Key: {}", identity.identity.public_key.key));
tree.add_empty_child(format!(
"Version: {}",
identity.identity.public_key.version
));
tree.add_empty_child(format!("Key: {}", identity.public_key.key));
tree.add_empty_child(format!("Version: {}", identity.public_key.version));
// User has changed credentials, so attempt to re-prove
if old_identity.identity.login != new_identity.identity.login
|| old_identity.identity.origin != new_identity.identity.origin
{
if !options.no_link && new_identity.link_remote {
new_identity.prove(self.no_cert_check).await?;
}
}
old_identity.replace_with(new_identity)?;