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:
parent
fe663cf43f
commit
7e94f76653
11 changed files with 982 additions and 59 deletions
907
Cargo.lock
generated
907
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -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"
|
||||||
|
|
|
@ -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![];
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}")]
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()?;
|
||||||
|
|
|
@ -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
24
src/encryption/keyring.rs
Normal 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()
|
||||||
|
}
|
29
src/main.rs
29
src/main.rs
|
@ -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() {
|
||||||
|
|
Loading…
Reference in a new issue