Skip to content

Commit

Permalink
Add support for storing encryption passkey in system keyring (#265)
Browse files Browse the repository at this point in the history
- 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
  • Loading branch information
dyc3 authored Jul 2, 2023
1 parent fe663cf commit 7e94f76
Show file tree
Hide file tree
Showing 11 changed files with 985 additions and 62 deletions.
913 changes: 859 additions & 54 deletions Cargo.lock

Large diffs are not rendered by default.

8 changes: 5 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ repository = "https://github.com/dyc3/steamguard-cli"
license = "GPL-3.0-or-later"

[features]
default = ["qr", "updater"]
qr = ["qrcode"]
updater = ["update-informer"]
default = ["qr", "updater", "keyring"]
qr = ["dep:qrcode"]
updater = ["dep:update-informer"]
keyring = ["dep:keyring"]

# [[bin]]
# name = "steamguard-cli"
Expand Down Expand Up @@ -63,6 +64,7 @@ update-informer = { version = "1.0.0", optional = true, default-features = false
phonenumber = "0.3"
cbc = { version = "0.1.2", features = ["std"] }
inout = { version = "0.1.3", features = ["std"] }
keyring = { version = "2.0.4", optional = true }

[dev-dependencies]
tempdir = "0.3"
Expand Down
12 changes: 12 additions & 0 deletions src/accountmanager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,18 @@ impl AccountManager {
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.
pub fn load_accounts(&mut self) -> anyhow::Result<(), ManifestAccountLoadError> {
let mut accounts = vec![];
Expand Down
1 change: 1 addition & 0 deletions src/accountmanager/legacy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ impl From<SdaManifest> for ManifestV1 {
Self {
version: 1,
entries: sda.entries.into_iter().map(|e| e.into()).collect(),
keyring_id: None,
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions src/accountmanager/manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ pub type ManifestEntry = ManifestEntryV1;
pub struct ManifestV1 {
pub version: u32,
pub entries: Vec<ManifestEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub keyring_id: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand All @@ -25,6 +27,7 @@ impl Default for ManifestV1 {
Self {
version: 1,
entries: vec![],
keyring_id: None,
}
}
}
4 changes: 2 additions & 2 deletions src/accountmanager/migrate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ fn do_migrate(
deserialize_manifest(buffer).map_err(MigrationError::ManifestDeserializeFailed)?;

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() {
// 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.")));
Expand Down Expand Up @@ -84,7 +84,7 @@ fn backup_file(path: &Path) -> anyhow::Result<()> {
#[derive(Debug, Error)]
pub(crate) enum MigrationError {
#[error("Passkey is required to decrypt manifest")]
MissingPasskey,
MissingPasskey { keyring_id: Option<String> },
#[error("Failed to deserialize manifest: {0}")]
ManifestDeserializeFailed(serde_path_to_error::Error<serde_json::Error>),
#[error("IO error when upgrading manifest: {0}")]
Expand Down
11 changes: 11 additions & 0 deletions src/commands/decrypt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ where
{
fn execute(&self, _transport: T, manager: &mut AccountManager) -> anyhow::Result<()> {
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() {
entry.encryption = None;
}
Expand Down
27 changes: 26 additions & 1 deletion src/commands/encrypt.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use log::*;

use crate::AccountManager;
use crate::{tui, AccountManager};

use super::*;

Expand Down Expand Up @@ -31,6 +31,31 @@ where
error!("Passkeys do not match, try again.");
}
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.load_accounts()?;
Expand Down
15 changes: 15 additions & 0 deletions src/encryption.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
use aes::cipher::block_padding::Pkcs7;
use aes::cipher::{BlockDecryptMut, BlockEncryptMut, InvalidLength, KeyIvInit};
use aes::Aes256;
use rand::Rng;
use ring::pbkdf2;
use ring::rand::SecureRandom;
use serde::{Deserialize, Serialize};
use thiserror::Error;

#[cfg(feature = "keyring")]
mod keyring;

#[cfg(feature = "keyring")]
pub use crate::encryption::keyring::*;

const SALT_LENGTH: usize = 8;
const IV_LENGTH: usize = 16;

Expand Down Expand Up @@ -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)]
mod tests {
use super::*;
Expand Down
24 changes: 24 additions & 0 deletions src/encryption/keyring.rs
Original file line number Diff line number Diff line change
@@ -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()
}
29 changes: 27 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,10 +140,22 @@ fn run(args: commands::Args) -> anyhow::Result<()> {
accounts = a;
break;
}
Err(MigrationError::MissingPasskey) => {
Err(MigrationError::MissingPasskey { keyring_id }) => {
if passkey.is_some() {
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 =
rpassword::prompt_password_stdout("Enter encryption passkey: ")?;
passkey = Some(SecretString::new(raw));
Expand All @@ -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);

loop {
Expand Down Expand Up @@ -219,7 +244,7 @@ fn run(args: commands::Args) -> anyhow::Result<()> {
break;
}
Err(
accountmanager::ManifestAccountLoadError::MissingPasskey
accountmanager::ManifestAccountLoadError::MissingPasskey { .. }
| accountmanager::ManifestAccountLoadError::IncorrectPasskey,
) => {
if manager.has_passkey() {
Expand Down

0 comments on commit 7e94f76

Please sign in to comment.