Skip to content

Commit d637431

Browse files
committed
feat(desktop): settings, identity CRUD, and TOTP
1 parent c7f6329 commit d637431

File tree

15 files changed

+1202
-28
lines changed

15 files changed

+1202
-28
lines changed

desktop/src-tauri/Cargo.lock

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

desktop/src-tauri/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ tracing = "0.1"
2424
dirs = "5.0"
2525
byteorder = "1.5"
2626
chrono = { version = "0.4", features = ["serde"] }
27+
data-encoding = "2.5"
28+
hmac = "0.12"
29+
sha1 = "0.10"
30+
sha2 = "0.10"
2731

2832
[features]
2933
default = ["custom-protocol"]

desktop/src-tauri/src/commands.rs

Lines changed: 191 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ use std::collections::HashMap;
1212
use std::path::PathBuf;
1313
use std::fs;
1414
use std::sync::Arc;
15+
use data_encoding::{BASE32, BASE32_NOPAD};
16+
use hmac::{Hmac, Mac};
17+
use sha1::Sha1;
18+
use sha2::{Sha256, Sha512};
1519

1620
/// Initialize the Persona service with master password
1721
#[command]
@@ -196,6 +200,85 @@ pub async fn get_identity(
196200
}
197201
}
198202

203+
/// Update an existing identity
204+
#[command]
205+
pub async fn update_identity(
206+
request: UpdateIdentityRequest,
207+
state: State<'_, AppState>,
208+
) -> std::result::Result<ApiResponse<SerializableIdentity>, String> {
209+
let service_guard = state.service.lock().await;
210+
match service_guard.as_ref() {
211+
Some(service) => {
212+
let uuid = match Uuid::from_str(&request.id) {
213+
Ok(uuid) => uuid,
214+
Err(_) => return Ok(ApiResponse::error("Invalid UUID format".to_string())),
215+
};
216+
217+
match service.get_identity(&uuid).await {
218+
Ok(Some(mut identity)) => {
219+
let identity_type = match request.identity_type.as_str() {
220+
"Personal" => IdentityType::Personal,
221+
"Work" => IdentityType::Work,
222+
"Social" => IdentityType::Social,
223+
"Financial" => IdentityType::Financial,
224+
"Gaming" => IdentityType::Gaming,
225+
custom => IdentityType::Custom(custom.to_string()),
226+
};
227+
228+
identity.name = request.name.trim().to_string();
229+
identity.identity_type = identity_type;
230+
identity.description = request.description.and_then(|s| {
231+
let trimmed = s.trim().to_string();
232+
if trimmed.is_empty() { None } else { Some(trimmed) }
233+
});
234+
identity.email = request.email.and_then(|s| {
235+
let trimmed = s.trim().to_string();
236+
if trimmed.is_empty() { None } else { Some(trimmed) }
237+
});
238+
identity.phone = request.phone.and_then(|s| {
239+
let trimmed = s.trim().to_string();
240+
if trimmed.is_empty() { None } else { Some(trimmed) }
241+
});
242+
if let Some(tags) = request.tags {
243+
identity.tags = tags
244+
.into_iter()
245+
.map(|t| t.trim().to_string())
246+
.filter(|t| !t.is_empty())
247+
.collect();
248+
}
249+
250+
match service.update_identity(&identity).await {
251+
Ok(updated_identity) => Ok(ApiResponse::success(updated_identity.into())),
252+
Err(e) => Ok(ApiResponse::error(format!("Failed to update identity: {}", e))),
253+
}
254+
}
255+
Ok(None) => Ok(ApiResponse::error("Identity not found".to_string())),
256+
Err(e) => Ok(ApiResponse::error(format!("Failed to get identity: {}", e))),
257+
}
258+
}
259+
None => Ok(ApiResponse::error("Service not initialized".to_string())),
260+
}
261+
}
262+
263+
/// Delete an identity
264+
#[command]
265+
pub async fn delete_identity(
266+
identity_id: String,
267+
state: State<'_, AppState>,
268+
) -> std::result::Result<ApiResponse<bool>, String> {
269+
let service_guard = state.service.lock().await;
270+
match service_guard.as_ref() {
271+
Some(service) => match Uuid::from_str(&identity_id) {
272+
Ok(uuid) => match service.delete_identity(&uuid).await {
273+
Ok(ok) => Ok(ApiResponse::success(ok)),
274+
Err(e) => Ok(ApiResponse::error(format!("Failed to delete identity: {}", e))),
275+
},
276+
Err(_) => Ok(ApiResponse::error("Invalid UUID format".to_string())),
277+
},
278+
None => Ok(ApiResponse::error("Service not initialized".to_string())),
279+
}
280+
}
281+
199282
/// Create a new credential
200283
#[command]
201284
pub async fn create_credential(
@@ -244,6 +327,17 @@ pub async fn create_credential(
244327
if let Some(username) = request.username {
245328
credential.username = Some(username);
246329
}
330+
if let Some(notes) = request.notes {
331+
let trimmed = notes.trim().to_string();
332+
credential.notes = if trimmed.is_empty() { None } else { Some(trimmed) };
333+
}
334+
if let Some(tags) = request.tags {
335+
credential.tags = tags
336+
.into_iter()
337+
.map(|t| t.trim().to_string())
338+
.filter(|t| !t.is_empty())
339+
.collect();
340+
}
247341

248342
match service.update_credential(&credential).await {
249343
Ok(updated_credential) => Ok(ApiResponse::success(updated_credential.into())),
@@ -305,8 +399,10 @@ pub async fn get_credential_data(
305399
CredentialData::CryptoWallet(_) => "CryptoWallet".to_string(),
306400
CredentialData::SshKey(_) => "SshKey".to_string(),
307401
CredentialData::ApiKey(_) => "ApiKey".to_string(),
402+
CredentialData::BankCard(_) => "BankCard".to_string(),
403+
CredentialData::ServerConfig(_) => "ServerConfig".to_string(),
404+
CredentialData::TwoFactor(_) => "TwoFactor".to_string(),
308405
CredentialData::Raw(_) => "Raw".to_string(),
309-
_ => "Unknown".to_string(),
310406
},
311407
data: credential_data_to_json(&data),
312408
});
@@ -322,6 +418,52 @@ pub async fn get_credential_data(
322418
}
323419
}
324420

421+
/// Generate a TOTP code for a TwoFactor credential (without exposing the secret)
422+
#[command]
423+
pub async fn get_totp_code(
424+
credential_id: String,
425+
state: State<'_, AppState>,
426+
) -> std::result::Result<ApiResponse<TotpCodeResponse>, String> {
427+
let service_guard = state.service.lock().await;
428+
let service = service_guard
429+
.as_ref()
430+
.ok_or_else(|| "Service not initialized".to_string())?;
431+
432+
let uuid = Uuid::from_str(&credential_id).map_err(|_| "Invalid UUID format".to_string())?;
433+
let credential_data = service
434+
.get_credential_data(&uuid)
435+
.await
436+
.map_err(|e| format!("Failed to get credential data: {}", e))?;
437+
438+
let data = credential_data.ok_or_else(|| "Credential not found".to_string())?;
439+
match data {
440+
CredentialData::TwoFactor(tf) => {
441+
let secret_bytes = decode_totp_secret(&tf.secret_key)?;
442+
let now = chrono::Utc::now();
443+
let period = tf.period.max(1) as u64;
444+
let timestamp = now.timestamp().max(0) as u64;
445+
let counter = timestamp / period;
446+
let digits = tf.digits.clamp(4, 10) as u32;
447+
let code_num = hotp(&secret_bytes, counter, &tf.algorithm)?;
448+
let modulo = 10_u32.pow(digits);
449+
let value = code_num % modulo;
450+
let code = format!("{:0width$}", value, width = digits as usize);
451+
let remaining = (period - (timestamp % period)) as u32;
452+
453+
Ok(ApiResponse::success(TotpCodeResponse {
454+
code,
455+
remaining_seconds: remaining,
456+
period: tf.period.max(1),
457+
digits: tf.digits.clamp(4, 10),
458+
algorithm: tf.algorithm,
459+
issuer: tf.issuer,
460+
account_name: tf.account_name,
461+
}))
462+
}
463+
_ => Ok(ApiResponse::error("Credential is not a TwoFactor entry".to_string())),
464+
}
465+
}
466+
325467
/// Search credentials
326468
#[command]
327469
pub async fn search_credentials(
@@ -343,6 +485,54 @@ pub async fn search_credentials(
343485
}
344486
}
345487

488+
fn hotp(secret: &[u8], counter: u64, algorithm: &str) -> std::result::Result<u32, String> {
489+
let msg = counter.to_be_bytes();
490+
let algo = algorithm.to_ascii_uppercase();
491+
492+
let hash = if algo == "SHA256" {
493+
type HmacSha256 = Hmac<Sha256>;
494+
let mut mac = HmacSha256::new_from_slice(secret).map_err(|_| "Invalid secret".to_string())?;
495+
mac.update(&msg);
496+
mac.finalize().into_bytes().to_vec()
497+
} else if algo == "SHA512" {
498+
type HmacSha512 = Hmac<Sha512>;
499+
let mut mac = HmacSha512::new_from_slice(secret).map_err(|_| "Invalid secret".to_string())?;
500+
mac.update(&msg);
501+
mac.finalize().into_bytes().to_vec()
502+
} else {
503+
type HmacSha1 = Hmac<Sha1>;
504+
let mut mac = HmacSha1::new_from_slice(secret).map_err(|_| "Invalid secret".to_string())?;
505+
mac.update(&msg);
506+
mac.finalize().into_bytes().to_vec()
507+
};
508+
509+
let offset = (hash.last().copied().unwrap_or(0) & 0x0f) as usize;
510+
if offset + 4 > hash.len() {
511+
return Err("Invalid HMAC output".to_string());
512+
}
513+
let slice = &hash[offset..offset + 4];
514+
let binary = ((slice[0] as u32 & 0x7f) << 24)
515+
| ((slice[1] as u32) << 16)
516+
| ((slice[2] as u32) << 8)
517+
| slice[3] as u32;
518+
Ok(binary)
519+
}
520+
521+
fn decode_totp_secret(secret: &str) -> std::result::Result<Vec<u8>, String> {
522+
let normalized: String = secret
523+
.chars()
524+
.filter(|c| !c.is_whitespace())
525+
.map(|c| c.to_ascii_uppercase())
526+
.collect::<String>()
527+
.trim_matches('=')
528+
.to_string();
529+
530+
BASE32_NOPAD
531+
.decode(normalized.as_bytes())
532+
.or_else(|_| BASE32.decode(normalized.as_bytes()))
533+
.map_err(|e| format!("Invalid base32 secret: {}", e))
534+
}
535+
346536
/// Generate password
347537
#[command]
348538
pub async fn generate_password(

desktop/src-tauri/src/main.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,12 @@ fn main() {
2121
commands::create_identity,
2222
commands::get_identities,
2323
commands::get_identity,
24+
commands::update_identity,
25+
commands::delete_identity,
2426
commands::create_credential,
2527
commands::get_credentials_for_identity,
2628
commands::get_credential_data,
29+
commands::get_totp_code,
2730
commands::search_credentials,
2831
commands::generate_password,
2932
commands::get_statistics,

0 commit comments

Comments
 (0)