Add support for storing encryption passkey in system keyring (#265)

- add keyring package
- add keyring id field to manifest
- automatically attempt to load encryption passkey from keyring
- have decrypt delete the passkey on decrypt
- have encrypt command ask if it should store the passkey in the keyring
- fix lints

closes #117
This commit is contained in:
Carson McManus 2023-07-02 10:44:18 -04:00 committed by GitHub
parent fe663cf43f
commit 7e94f76653
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 982 additions and 59 deletions

907
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -14,9 +14,10 @@ repository = "https://github.com/dyc3/steamguard-cli"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
[features] [features]
default = ["qr", "updater"] default = ["qr", "updater", "keyring"]
qr = ["qrcode"] qr = ["dep:qrcode"]
updater = ["update-informer"] updater = ["dep:update-informer"]
keyring = ["dep:keyring"]
# [[bin]] # [[bin]]
# name = "steamguard-cli" # name = "steamguard-cli"
@ -63,6 +64,7 @@ update-informer = { version = "1.0.0", optional = true, default-features = false
phonenumber = "0.3" phonenumber = "0.3"
cbc = { version = "0.1.2", features = ["std"] } cbc = { version = "0.1.2", features = ["std"] }
inout = { version = "0.1.3", features = ["std"] } inout = { version = "0.1.3", features = ["std"] }
keyring = { version = "2.0.4", optional = true }
[dev-dependencies] [dev-dependencies]
tempdir = "0.3" tempdir = "0.3"

View file

@ -88,6 +88,18 @@ impl AccountManager {
self.passkey = passkey; self.passkey = passkey;
} }
pub fn keyring_id(&self) -> Option<&String> {
self.manifest.keyring_id.as_ref()
}
pub fn set_keyring_id(&mut self, keyring_id: String) {
self.manifest.keyring_id = Some(keyring_id);
}
pub fn clear_keyring_id(&mut self) {
self.manifest.keyring_id = None;
}
/// Loads all accounts, and registers them. /// Loads all accounts, and registers them.
pub fn load_accounts(&mut self) -> anyhow::Result<(), ManifestAccountLoadError> { pub fn load_accounts(&mut self) -> anyhow::Result<(), ManifestAccountLoadError> {
let mut accounts = vec![]; let mut accounts = vec![];

View file

@ -44,6 +44,7 @@ impl From<SdaManifest> for ManifestV1 {
Self { Self {
version: 1, version: 1,
entries: sda.entries.into_iter().map(|e| e.into()).collect(), entries: sda.entries.into_iter().map(|e| e.into()).collect(),
keyring_id: None,
} }
} }
} }

View file

@ -10,6 +10,8 @@ pub type ManifestEntry = ManifestEntryV1;
pub struct ManifestV1 { pub struct ManifestV1 {
pub version: u32, pub version: u32,
pub entries: Vec<ManifestEntry>, pub entries: Vec<ManifestEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub keyring_id: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -25,6 +27,7 @@ impl Default for ManifestV1 {
Self { Self {
version: 1, version: 1,
entries: vec![], entries: vec![],
keyring_id: None,
} }
} }
} }

View file

@ -45,7 +45,7 @@ fn do_migrate(
deserialize_manifest(buffer).map_err(MigrationError::ManifestDeserializeFailed)?; deserialize_manifest(buffer).map_err(MigrationError::ManifestDeserializeFailed)?;
if manifest.is_encrypted() && passkey.is_none() { if manifest.is_encrypted() && passkey.is_none() {
return Err(MigrationError::MissingPasskey); return Err(MigrationError::MissingPasskey { keyring_id: None });
} else if !manifest.is_encrypted() && passkey.is_some() { } else if !manifest.is_encrypted() && passkey.is_some() {
// no custom error because this is an edge case, mostly user error // no custom error because this is an edge case, mostly user error
return Err(MigrationError::UnexpectedError(anyhow::anyhow!("A passkey was provided but the manifest is not encrypted. Aborting migration because it would encrypt the maFiles, and you probably didn't mean to do that."))); return Err(MigrationError::UnexpectedError(anyhow::anyhow!("A passkey was provided but the manifest is not encrypted. Aborting migration because it would encrypt the maFiles, and you probably didn't mean to do that.")));
@ -84,7 +84,7 @@ fn backup_file(path: &Path) -> anyhow::Result<()> {
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub(crate) enum MigrationError { pub(crate) enum MigrationError {
#[error("Passkey is required to decrypt manifest")] #[error("Passkey is required to decrypt manifest")]
MissingPasskey, MissingPasskey { keyring_id: Option<String> },
#[error("Failed to deserialize manifest: {0}")] #[error("Failed to deserialize manifest: {0}")]
ManifestDeserializeFailed(serde_path_to_error::Error<serde_json::Error>), ManifestDeserializeFailed(serde_path_to_error::Error<serde_json::Error>),
#[error("IO error when upgrading manifest: {0}")] #[error("IO error when upgrading manifest: {0}")]

View file

@ -14,6 +14,17 @@ where
{ {
fn execute(&self, _transport: T, manager: &mut AccountManager) -> anyhow::Result<()> { fn execute(&self, _transport: T, manager: &mut AccountManager) -> anyhow::Result<()> {
load_accounts_with_prompts(manager)?; load_accounts_with_prompts(manager)?;
#[cfg(feature = "keyring")]
if let Some(keyring_id) = manager.keyring_id() {
match crate::encryption::clear_passkey(keyring_id.clone()) {
Ok(_) => {
info!("Cleared passkey from keyring");
manager.clear_keyring_id();
}
Err(e) => warn!("Failed to clear passkey from keyring: {}", e),
}
}
for mut entry in manager.iter_mut() { for mut entry in manager.iter_mut() {
entry.encryption = None; entry.encryption = None;
} }

View file

@ -1,6 +1,6 @@
use log::*; use log::*;
use crate::AccountManager; use crate::{tui, AccountManager};
use super::*; use super::*;
@ -31,6 +31,31 @@ where
error!("Passkeys do not match, try again."); error!("Passkeys do not match, try again.");
} }
let passkey = passkey.map(SecretString::new); let passkey = passkey.map(SecretString::new);
#[cfg(feature = "keyring")]
{
if tui::prompt_char(
"Would you like to store the passkey in your system keyring?",
"yn",
) == 'y'
{
let keyring_id = crate::encryption::generate_keyring_id();
match crate::encryption::store_passkey(
keyring_id.clone(),
passkey.clone().unwrap(),
) {
Ok(_) => {
info!("Stored passkey in keyring");
manager.set_keyring_id(keyring_id);
}
Err(e) => warn!(
"Failed to store passkey in keyring, continuing anyway: {}",
e
),
}
}
}
manager.submit_passkey(passkey); manager.submit_passkey(passkey);
} }
manager.load_accounts()?; manager.load_accounts()?;

View file

@ -1,11 +1,18 @@
use aes::cipher::block_padding::Pkcs7; use aes::cipher::block_padding::Pkcs7;
use aes::cipher::{BlockDecryptMut, BlockEncryptMut, InvalidLength, KeyIvInit}; use aes::cipher::{BlockDecryptMut, BlockEncryptMut, InvalidLength, KeyIvInit};
use aes::Aes256; use aes::Aes256;
use rand::Rng;
use ring::pbkdf2; use ring::pbkdf2;
use ring::rand::SecureRandom; use ring::rand::SecureRandom;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
#[cfg(feature = "keyring")]
mod keyring;
#[cfg(feature = "keyring")]
pub use crate::encryption::keyring::*;
const SALT_LENGTH: usize = 8; const SALT_LENGTH: usize = 8;
const IV_LENGTH: usize = 16; const IV_LENGTH: usize = 16;
@ -158,6 +165,14 @@ impl From<std::io::Error> for EntryEncryptionError {
} }
} }
pub fn generate_keyring_id() -> String {
let rng = rand::thread_rng();
rng.sample_iter(rand::distributions::Alphanumeric)
.take(32)
.map(char::from)
.collect()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

24
src/encryption/keyring.rs Normal file
View file

@ -0,0 +1,24 @@
use keyring::Entry;
use secrecy::{ExposeSecret, SecretString};
const KEYRING_SERVICE: &str = "steamguard-cli";
pub fn init_keyring(keyring_id: String) -> keyring::Result<Entry> {
Entry::new(KEYRING_SERVICE, &keyring_id)
}
pub fn try_passkey_from_keyring(keyring_id: String) -> keyring::Result<Option<SecretString>> {
let entry = init_keyring(keyring_id)?;
let passkey = entry.get_password()?;
Ok(Some(SecretString::new(passkey)))
}
pub fn store_passkey(keyring_id: String, passkey: SecretString) -> keyring::Result<()> {
let entry = init_keyring(keyring_id)?;
entry.set_password(passkey.expose_secret())
}
pub fn clear_passkey(keyring_id: String) -> keyring::Result<()> {
let entry = init_keyring(keyring_id)?;
entry.delete_password()
}

View file

@ -140,10 +140,22 @@ fn run(args: commands::Args) -> anyhow::Result<()> {
accounts = a; accounts = a;
break; break;
} }
Err(MigrationError::MissingPasskey) => { Err(MigrationError::MissingPasskey { keyring_id }) => {
if passkey.is_some() { if passkey.is_some() {
error!("Incorrect passkey"); error!("Incorrect passkey");
} }
#[cfg(feature = "keyring")]
if let Some(keyring_id) = keyring_id {
if passkey.is_none() {
info!("Attempting to load encryption passkey from keyring");
let entry = encryption::init_keyring(keyring_id)?;
let raw = entry.get_password()?;
passkey = Some(SecretString::new(raw));
continue;
}
}
let raw = let raw =
rpassword::prompt_password_stdout("Enter encryption passkey: ")?; rpassword::prompt_password_stdout("Enter encryption passkey: ")?;
passkey = Some(SecretString::new(raw)); passkey = Some(SecretString::new(raw));
@ -167,6 +179,19 @@ fn run(args: commands::Args) -> anyhow::Result<()> {
} }
} }
#[cfg(feature = "keyring")]
if let Some(keyring_id) = manager.keyring_id() {
if passkey.is_none() {
info!("Attempting to load encryption passkey from keyring");
match encryption::try_passkey_from_keyring(keyring_id.clone()) {
Ok(k) => passkey = k,
Err(e) => {
warn!("Failed to load encryption passkey from keyring: {}", e);
}
}
}
}
manager.submit_passkey(passkey); manager.submit_passkey(passkey);
loop { loop {
@ -219,7 +244,7 @@ fn run(args: commands::Args) -> anyhow::Result<()> {
break; break;
} }
Err( Err(
accountmanager::ManifestAccountLoadError::MissingPasskey accountmanager::ManifestAccountLoadError::MissingPasskey { .. }
| accountmanager::ManifestAccountLoadError::IncorrectPasskey, | accountmanager::ManifestAccountLoadError::IncorrectPasskey,
) => { ) => {
if manager.has_passkey() { if manager.has_passkey() {