diff --git a/AGENTS.md b/AGENTS.md index 09cad5e..7b1d028 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,3 +45,12 @@ cargo test --workspace For Apple project changes, also run `xcodegen generate` from `apple/` and build the affected simulator targets. + +### Simulator biometric validation + +Before testing biometric unlock, enable Biometric Unlock once in the iPhone +app so its protected passphrase is enrolled. In Simulator, use **Features > +Face ID > Enrolled**, trigger an IronStorage unlock, then choose **Matching +Face**. Verify the app changes from locked to unlocked. Use **Non-matching +Face** to test rejection, and clear **Enrolled** to test unavailable or changed +biometric enrollment. diff --git a/apple/Generated/ironstorage_apple.swift b/apple/Generated/ironstorage_apple.swift index 5efae92..873863c 100644 --- a/apple/Generated/ironstorage_apple.swift +++ b/apple/Generated/ironstorage_apple.swift @@ -625,6 +625,8 @@ public protocol MobileAuthenticationProtocol: AnyObject, Sendable { func generateEntryEditorPassword(editor: UInt64, length: UInt32?, noSymbols: Bool) throws -> MobileEntryEditorPage + func gitIdentity() throws -> MobileGitIdentity + func manualLock() throws func performEntryMutation(request: MobileMutationRequest) throws -> MobileMutationOutcome @@ -643,6 +645,8 @@ public protocol MobileAuthenticationProtocol: AnyObject, Sendable { func setBiometricUnlock(enabled: Bool) throws -> MobileAuthenticationState + func setGitIdentity(name: String, email: String) throws -> MobileGitIdentity + func setTotpWatchShared(path: String, shared: Bool, unixSeconds: UInt64) throws -> MobileTotpDetail func state() throws -> MobileAuthenticationState @@ -829,6 +833,15 @@ open func generateEntryEditorPassword(editor: UInt64, length: UInt32?, noSymbols }) } +open func gitIdentity()throws -> MobileGitIdentity { + return try FfiConverterTypeMobileGitIdentity_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobileauthentication_git_identity( + self.uniffiCloneHandle(),uniffiCallStatus + ) +}) +} + open func manualLock()throws {try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { uniffiCallStatus in uniffi_ironstorage_apple_fn_method_mobileauthentication_manual_lock( @@ -925,6 +938,17 @@ open func setBiometricUnlock(enabled: Bool)throws -> MobileAuthenticationState }) } +open func setGitIdentity(name: String, email: String)throws -> MobileGitIdentity { + return try FfiConverterTypeMobileGitIdentity_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobileauthentication_set_git_identity( + self.uniffiCloneHandle(), + FfiConverterString.lower(name), + FfiConverterString.lower(email),uniffiCallStatus + ) +}) +} + open func setTotpWatchShared(path: String, shared: Bool, unixSeconds: UInt64)throws -> MobileTotpDetail { return try FfiConverterTypeMobileTotpDetail_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { uniffiCallStatus in @@ -2437,6 +2461,60 @@ public func FfiConverterTypeMobileEntrySection_lower(_ value: MobileEntrySection } +public struct MobileGitIdentity: Equatable, Hashable { + public var name: String + public var email: String + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(name: String, email: String) { + self.name = name + self.email = email + } + + + + +} + +#if compiler(>=6) +extension MobileGitIdentity: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileGitIdentity: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileGitIdentity { + return + try MobileGitIdentity( + name: FfiConverterString.read(from: &buf), + email: FfiConverterString.read(from: &buf) + ) + } + + public static func write(_ value: MobileGitIdentity, into buf: inout [UInt8]) { + FfiConverterString.write(value.name, into: &buf) + FfiConverterString.write(value.email, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileGitIdentity_lift(_ buf: RustBuffer) throws -> MobileGitIdentity { + return try FfiConverterTypeMobileGitIdentity.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileGitIdentity_lower(_ value: MobileGitIdentity) -> RustBuffer { + return FfiConverterTypeMobileGitIdentity.lower(value) +} + + public struct MobileHomeChange: Equatable, Hashable { public var id: String public var title: String @@ -6864,6 +6942,9 @@ private let initializationResult: InitializationResult = { if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_generate_entry_editor_password() != 34289) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_git_identity() != 29496) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_manual_lock() != 57220) { return InitializationResult.apiChecksumMismatch } @@ -6891,6 +6972,9 @@ private let initializationResult: InitializationResult = { if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_biometric_unlock() != 9486) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_git_identity() != 62371) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_totp_watch_shared() != 57472) { return InitializationResult.apiChecksumMismatch } diff --git a/apple/Generated/ironstorage_appleFFI.h b/apple/Generated/ironstorage_appleFFI.h index a1ac93b..06b5b11 100644 --- a/apple/Generated/ironstorage_appleFFI.h +++ b/apple/Generated/ironstorage_appleFFI.h @@ -308,6 +308,11 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_entry_page(ui RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_generate_entry_editor_password(uint64_t ptr, uint64_t editor, RustBuffer length, int8_t no_symbols, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_GIT_IDENTITY +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_GIT_IDENTITY +RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_git_identity(uint64_t ptr, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_MANUAL_LOCK #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_MANUAL_LOCK void uniffi_ironstorage_apple_fn_method_mobileauthentication_manual_lock(uint64_t ptr, RustCallStatus *_Nonnull out_status @@ -353,6 +358,11 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_save_entry_ed RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_set_biometric_unlock(uint64_t ptr, int8_t enabled, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_GIT_IDENTITY +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_GIT_IDENTITY +RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_set_git_identity(uint64_t ptr, RustBuffer name, RustBuffer email, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_TOTP_WATCH_SHARED #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_TOTP_WATCH_SHARED RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_set_totp_watch_shared(uint64_t ptr, RustBuffer path, int8_t shared, uint64_t unix_seconds, RustCallStatus *_Nonnull out_status @@ -990,6 +1000,12 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_entry_pag #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_GENERATE_ENTRY_EDITOR_PASSWORD uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_generate_entry_editor_password(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_GIT_IDENTITY +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_GIT_IDENTITY +uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_git_identity(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_MANUAL_LOCK @@ -1044,6 +1060,12 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_save_entr #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_biometric_unlock(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_GIT_IDENTITY +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_GIT_IDENTITY +uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_git_identity(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_TOTP_WATCH_SHARED diff --git a/apple/Sources/App/IronStorageApp.swift b/apple/Sources/App/IronStorageApp.swift index 6d4481d..176fb3b 100644 --- a/apple/Sources/App/IronStorageApp.swift +++ b/apple/Sources/App/IronStorageApp.swift @@ -712,6 +712,7 @@ private final class PreferencesViewController: UITableViewController, MobileTabR private let authentication: MobileAuthentication? private let keyTransfer = try? mobileKeyTransfer() private var state: MobileAuthenticationState? + private var gitIdentity: MobileGitIdentity? private var preferenceTask: Task? private var loadTask: Task? private var loadGeneration = 0 @@ -753,14 +754,14 @@ private final class PreferencesViewController: UITableViewController, MobileTabR } override func numberOfSections(in tableView: UITableView) -> Int { - page.state == .ready ? 3 : 0 + page.state == .ready ? 4 : 0 } override func tableView( _ tableView: UITableView, numberOfRowsInSection section: Int ) -> Int { - section == 0 ? 2 : (section == 1 ? 1 : 2) + section == 1 ? 2 : (section == 3 ? 2 : 1) } override func tableView( @@ -768,8 +769,9 @@ private final class PreferencesViewController: UITableViewController, MobileTabR titleForHeaderInSection section: Int ) -> String? { switch section { - case 0: "GPG Key Transfer" - case 1: "Secure Unlock" + case 0: "Git Commit Identity" + case 1: "GPG Key Transfer" + case 2: "Secure Unlock" default: "Authentication Session" } } @@ -779,9 +781,12 @@ private final class PreferencesViewController: UITableViewController, MobileTabR titleForFooterInSection section: Int ) -> String? { if section == 0 { - return "Scan or display ASCII-armored GPG keys. Private-key transfers require explicit confirmation and passphrase validation." + return "Used as the author for commits created by the iPhone app." } if section == 1 { + return "Scan or display ASCII-armored GPG keys. Private-key transfers require explicit confirmation and passphrase validation." + } + if section == 2 { return "When enabled, the GPG passphrase is device-only, requires a device passcode, and is invalidated when enrolled biometrics change." } return "Manual lock and inactivity expiry immediately revoke the shared Rust authentication lease." @@ -794,6 +799,13 @@ private final class PreferencesViewController: UITableViewController, MobileTabR let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil) var content = cell.defaultContentConfiguration() if indexPath.section == 0 { + content.image = UIImage(systemName: "person.crop.circle") + content.text = gitIdentity?.name ?? "Git Commit Identity" + content.secondaryText = gitIdentity?.email ?? "Set the commit author name and email" + cell.accessoryType = .disclosureIndicator + cell.isUserInteractionEnabled = authentication != nil + cell.contentView.alpha = authentication == nil ? 0.45 : 1 + } else if indexPath.section == 1 { let importing = indexPath.row == 0 content.image = UIImage(systemName: importing ? "qrcode.viewfinder" : "qrcode") content.text = importing ? "Import GPG Key" : "Export GPG Key" @@ -803,7 +815,7 @@ private final class PreferencesViewController: UITableViewController, MobileTabR cell.accessoryType = .disclosureIndicator cell.isUserInteractionEnabled = keyTransfer != nil cell.contentView.alpha = keyTransfer == nil ? 0.45 : 1 - } else if indexPath.section == 1 { + } else if indexPath.section == 2 { content.image = UIImage(systemName: "faceid") content.text = "Biometric Unlock" content.secondaryText = state?.biometricUnlockEnabled == true @@ -841,6 +853,10 @@ private final class PreferencesViewController: UITableViewController, MobileTabR override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) if indexPath.section == 0 { + editGitIdentity() + return + } + if indexPath.section == 1 { guard let keyTransfer else { return } if indexPath.row == 0 { requestKeyScanner(keyTransfer) @@ -852,7 +868,7 @@ private final class PreferencesViewController: UITableViewController, MobileTabR } return } - guard indexPath.section == 2, indexPath.row == 1, let authentication else { return } + guard indexPath.section == 3, indexPath.row == 1, let authentication else { return } do { try authentication.manualLock() refreshState() @@ -970,6 +986,81 @@ private final class PreferencesViewController: UITableViewController, MobileTabR present(alert, animated: true) } + private func editGitIdentity() { + guard let authentication else { + presentAuthenticationFailure(.unavailable) + return + } + let alert = UIAlertController( + title: "Git Commit Identity", + message: "Enter the author used for commits created on this iPhone.", + preferredStyle: .alert + ) + alert.addTextField { [gitIdentity] field in + field.placeholder = "Name" + field.text = gitIdentity?.name + field.textContentType = .name + field.autocapitalizationType = .words + field.clearButtonMode = .whileEditing + } + alert.addTextField { [gitIdentity] field in + field.placeholder = "Email" + field.text = gitIdentity?.email + field.textContentType = .emailAddress + field.keyboardType = .emailAddress + field.autocapitalizationType = .none + field.autocorrectionType = .no + field.clearButtonMode = .whileEditing + } + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + alert.addAction(UIAlertAction(title: "Save", style: .default) { [weak self, weak alert] _ in + guard + let self, + let fields = alert?.textFields, + fields.count == 2 + else { return } + saveGitIdentity( + authentication, + name: fields[0].text ?? "", + email: fields[1].text ?? "" + ) + }) + present(alert, animated: true) + } + + private func saveGitIdentity( + _ authentication: MobileAuthentication, + name: String, + email: String + ) { + preferenceTask?.cancel() + preferenceTask = Task { [weak self] in + let result = await Task.detached(priority: .userInitiated) { + do { + return Result.success( + try authentication.setGitIdentity(name: name, email: email) + ) + } catch let error as MobileAuthenticationFfiError { + return .failure(AuthenticationFailure(error)) + } catch { + return .failure(.unexpected) + } + }.value + guard !Task.isCancelled, let self else { return } + switch result { + case let .success(identity): + gitIdentity = identity + tableView.reloadSections(IndexSet(integer: 0), with: .automatic) + UIAccessibility.post( + notification: .announcement, + argument: "Git commit identity updated" + ) + case let .failure(failure): + presentAuthenticationFailure(failure) + } + } + } + @objc private func authenticationDidChange() { refreshState() } @@ -1014,6 +1105,7 @@ private final class PreferencesViewController: UITableViewController, MobileTabR private func refreshState() { state = try? authentication?.state() + gitIdentity = try? authentication?.gitIdentity() tableView.reloadData() } } diff --git a/crates/apple/src/lib.rs b/crates/apple/src/lib.rs index d4e5941..e1931fb 100644 --- a/crates/apple/src/lib.rs +++ b/crates/apple/src/lib.rs @@ -567,6 +567,12 @@ pub struct MobileAuthenticationState { pub remaining_seconds: u64, } +#[derive(Clone, uniffi::Record)] +pub struct MobileGitIdentity { + pub name: String, + pub email: String, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] pub enum MobileMutationAction { Move, @@ -1328,6 +1334,30 @@ impl MobileAuthentication { .map_err(Into::into) } + pub fn git_identity(&self) -> Result { + self.authentication + .git_identity() + .map(|identity| MobileGitIdentity { + name: identity.name().to_owned(), + email: identity.email().to_owned(), + }) + .map_err(Into::into) + } + + pub fn set_git_identity( + &self, + name: String, + email: String, + ) -> Result { + self.authentication + .set_git_identity(name, email) + .map(|identity| MobileGitIdentity { + name: identity.name().to_owned(), + email: identity.email().to_owned(), + }) + .map_err(Into::into) + } + pub fn unlock_entry( &self, path: String, diff --git a/crates/storage/src/config.rs b/crates/storage/src/config.rs index cd0c674..6c49086 100644 --- a/crates/storage/src/config.rs +++ b/crates/storage/src/config.rs @@ -16,10 +16,13 @@ use cap_tempfile::TempFile; use serde::Deserialize; use url::Url; -use crate::authentication::{AuthenticationTimeout, DEFAULT_AUTHENTICATION_TIMEOUT}; -use crate::mobile::MobileTab; -use crate::presentation::{ClipboardTimeout, DEFAULT_CLIPBOARD_TIMEOUT}; -use crate::repository::EntryPath; +use crate::{ + authentication::{AuthenticationTimeout, DEFAULT_AUTHENTICATION_TIMEOUT}, + git::GitIdentity, + mobile::MobileTab, + presentation::{ClipboardTimeout, DEFAULT_CLIPBOARD_TIMEOUT}, + repository::EntryPath, +}; const APPLICATION_DIRECTORY: &str = "ironstorage"; const CONFIG_FILE: &str = "config.toml"; @@ -40,6 +43,7 @@ pub struct Config { mobile_tab: MobileTab, mobile_home_refreshed_at: Option, watch_shared_totp_entries: BTreeSet, + git_identity: GitIdentity, git_remotes: Vec, } @@ -145,6 +149,10 @@ impl Config { &self.git_remotes } + pub fn git_identity(&self) -> &GitIdentity { + &self.git_identity + } + pub fn settings(&self) -> ConfigSettings { let editor = self.editor.as_ref().map(|editor| { std::iter::once(editor.program.clone()) @@ -272,6 +280,35 @@ impl Config { validate_config(self.source.clone(), document, raw)?.persist() } + pub fn update_git_identity(&self, identity: &GitIdentity) -> Result<(), ConfigError> { + let mut document = self.current_document()?; + let root = document + .as_table_mut() + .ok_or_else(|| ConfigError::Malformed { + path: self.source.clone(), + })?; + let git = root + .entry("git") + .or_insert_with(|| toml::Value::Table(toml::Table::new())) + .as_table_mut() + .ok_or(ConfigError::InvalidField { field: "git" })?; + git.insert( + "user_name".to_owned(), + toml::Value::String(identity.name().to_owned()), + ); + git.insert( + "user_email".to_owned(), + toml::Value::String(identity.email().to_owned()), + ); + let raw = document + .clone() + .try_into::() + .map_err(|_| ConfigError::Malformed { + path: self.source.clone(), + })?; + validate_config(self.source.clone(), document, raw)?.persist() + } + fn current_document(&self) -> Result { Self::load(Some(&self.source)).map(|config| config.document) } @@ -930,6 +967,8 @@ enum RawEditor { #[derive(Default, Deserialize)] #[serde(deny_unknown_fields)] struct RawGit { + user_name: Option, + user_email: Option, #[serde(default)] remotes: Vec, } @@ -1019,6 +1058,19 @@ fn validate_config( }) }) .collect::, _>>()?; + let git_identity = match (raw.git.user_name, raw.git.user_email) { + (None, None) => GitIdentity::ironstorage(), + (Some(name), Some(email)) => { + GitIdentity::new(name, email).map_err(|_| ConfigError::InvalidField { + field: "git.user_identity", + })? + } + _ => { + return Err(ConfigError::InvalidField { + field: "git.user_identity", + }); + } + }; let git_remotes = validate_remotes(raw.git.remotes)?; Ok(Config { @@ -1034,6 +1086,7 @@ fn validate_config( mobile_tab, mobile_home_refreshed_at, watch_shared_totp_entries, + git_identity, git_remotes, }) } @@ -1228,7 +1281,7 @@ fn validate_known_fields(value: &toml::Value, source: &Path) -> Result<(), Confi let git = git.as_table().ok_or_else(|| ConfigError::Malformed { path: source.to_owned(), })?; - validate_table(git, "git", &["remotes"])?; + validate_table(git, "git", &["user_name", "user_email", "remotes"])?; if let Some(remotes) = git.get("remotes") { let remotes = remotes.as_array().ok_or_else(|| ConfigError::Malformed { path: source.to_owned(), diff --git a/crates/storage/src/mobile_authentication.rs b/crates/storage/src/mobile_authentication.rs index 2e6592e..51326ff 100644 --- a/crates/storage/src/mobile_authentication.rs +++ b/crates/storage/src/mobile_authentication.rs @@ -190,6 +190,7 @@ struct ActiveMobileLease { struct MobileAuthenticationStatus { biometric_unlock_enabled: bool, + git_identity: GitIdentity, active: Option, next_editor_id: u64, editors: BTreeMap, @@ -226,6 +227,7 @@ impl MobileAuthentication { Ok(Self { status: Mutex::new(MobileAuthenticationStatus { biometric_unlock_enabled: config.biometric_unlock_enabled(), + git_identity: config.git_identity().clone(), active: None, next_editor_id: 0, editors: BTreeMap::new(), @@ -394,6 +396,29 @@ impl MobileAuthentication { }) } + pub fn git_identity(&self) -> Result { + Ok(self.status()?.git_identity.clone()) + } + + pub fn set_git_identity( + &self, + name: String, + email: String, + ) -> Result { + let identity = GitIdentity::new(name, email).map_err(|_| { + MobileAuthenticationError::new( + MobileAuthenticationErrorKind::Configuration, + "Git Identity Is Invalid", + "Enter a non-empty name and email without line breaks or angle brackets.", + ) + })?; + self.config + .update_git_identity(&identity) + .map_err(config_error)?; + self.status()?.git_identity = identity.clone(); + Ok(identity) + } + pub fn touch_user_activity(&self) -> Result<(), MobileAuthenticationError> { let status = self.status()?; let active = status.active.as_ref().ok_or_else(|| { @@ -483,17 +508,18 @@ impl MobileAuthentication { &self, request: MobileMutationRequest, ) -> Result { - let (handle, key) = { + let (handle, key, identity) = { let status = self.status()?; let active = status.active.as_ref().ok_or_else(locked_error)?; - (active.handle.clone(), active.key.clone()) + ( + active.handle.clone(), + active.key.clone(), + status.git_identity.clone(), + ) }; - let mut committer = AutomaticTreeCommitter::for_source( - &self.repository, - &request.source, - GitIdentity::ironstorage(), - ) - .map_err(git_mutation_error)?; + let mut committer = + AutomaticTreeCommitter::for_source(&self.repository, &request.source, identity) + .map_err(git_mutation_error)?; let has_open_editor = self.editor_state(&request.source)?.0; let editors = if has_open_editor && request.discard_editor { self.take_entry_editors(&request.source)? @@ -632,9 +658,9 @@ impl MobileAuthentication { document .replace_field_value(EntryFieldId::from_value(field), value.into_bytes()) .map_err(document_error)?; - let mut committer = - AutomaticEntryCommitter::for_entry(&self.repository, path, GitIdentity::ironstorage()) - .map_err(|error| entry_detail("Password Entry Could Not Be Saved", error))?; + let identity = self.git_identity()?; + let mut committer = AutomaticEntryCommitter::for_entry(&self.repository, path, identity) + .map_err(|error| entry_detail("Password Entry Could Not Be Saved", error))?; EntryDocumentService::new(&self.repository, &self.keys) .save_recoverable(&document, None, &mut committer) .map_err(document_error)?; @@ -795,17 +821,15 @@ impl MobileAuthentication { return Err(editor_error(error)); } let path = draft.document().path().to_string(); - let mut committer = match AutomaticEntryCommitter::for_entry( - &self.repository, - &path, - GitIdentity::ironstorage(), - ) { - Ok(committer) => committer, - Err(error) => { - self.restore_editor(editor, draft)?; - return Err(entry_detail("Password Entry Could Not Be Saved", error)); - } - }; + let identity = self.git_identity()?; + let mut committer = + match AutomaticEntryCommitter::for_entry(&self.repository, &path, identity) { + Ok(committer) => committer, + Err(error) => { + self.restore_editor(editor, draft)?; + return Err(entry_detail("Password Entry Could Not Be Saved", error)); + } + }; if let Err(error) = EntryDocumentService::new(&self.repository, &self.keys) .save_recoverable(draft.document(), None, &mut committer) { diff --git a/crates/storage/src/mobile_home.rs b/crates/storage/src/mobile_home.rs index e882b2a..72c3842 100644 --- a/crates/storage/src/mobile_home.rs +++ b/crates/storage/src/mobile_home.rs @@ -12,8 +12,8 @@ use std::{ use crate::{ config::{Config, ConfigError, GitRemote}, git::{ - GitChangeKind, GitCommitActivity, GitDivergence, GitError, GitIdentity, - GitOperationControl, GitProgressPhase, GitRepository, PullOutcome, + GitChangeKind, GitCommitActivity, GitDivergence, GitError, GitOperationControl, + GitProgressPhase, GitRepository, PullOutcome, }, repository::{Repository, RepositoryError}, secret_store::{ @@ -343,7 +343,7 @@ impl MobileHomeStorage { let config = Config::load(None).map_err(MobileHomeError::from_config)?; let repository = Repository::open(config.vault()).map_err(MobileHomeError::from_repository)?; - let git = GitRepository::open(&repository, GitIdentity::ironstorage()) + let git = GitRepository::open(&repository, config.git_identity().clone()) .map_err(MobileHomeError::from_git)?; let remote = config .git_remote(None) diff --git a/crates/storage/tests/config_contract.rs b/crates/storage/tests/config_contract.rs index 4df40be..30d3d7a 100644 --- a/crates/storage/tests/config_contract.rs +++ b/crates/storage/tests/config_contract.rs @@ -7,6 +7,7 @@ use ironstorage::{ authentication::{DEFAULT_AUTHENTICATION_TIMEOUT, MAX_AUTHENTICATION_TIMEOUT}, config::{ConfigError, ConfigLoader, EditorSource}, desktop::DesktopStorage, + git::GitIdentity, mobile::MobileTab, repository::EntryPath, }; @@ -98,6 +99,38 @@ fn explicit_relative_configuration_resolves_deterministically() -> TestResult { Ok(()) } +#[test] +fn git_commit_identity_defaults_validates_and_persists() -> TestResult { + let fixture = ConfigurationFixture::new()?; + fixture.write_explicit(fixture.valid_contents())?; + let config = fixture.loader().load(Some(&fixture.explicit_path()))?; + assert_eq!(config.git_identity(), &GitIdentity::ironstorage()); + + let identity = GitIdentity::new("Alice Example", "alice@example.test")?; + config.update_git_identity(&identity)?; + let reloaded = fixture.loader().load(Some(&fixture.explicit_path()))?; + assert_eq!(reloaded.git_identity(), &identity); + assert_eq!(reloaded.git_remotes(), config.git_remotes()); + let contents = fs::read_to_string(fixture.explicit_path())?; + assert!(contents.contains("user_name = \"Alice Example\"")); + assert!(contents.contains("user_email = \"alice@example.test\"")); + + fixture.write_explicit(&fixture.valid_contents().replace( + "[[git.remotes]]", + "[git]\nuser_name = \"Alice Example\"\n\n[[git.remotes]]", + ))?; + assert_eq!( + fixture + .loader() + .load(Some(&fixture.explicit_path())) + .expect_err("partial Git identity must be rejected"), + ConfigError::InvalidField { + field: "git.user_identity" + } + ); + Ok(()) +} + #[test] fn watch_totp_selection_persists_only_in_application_configuration() -> TestResult { let fixture = ConfigurationFixture::new()?; diff --git a/docs/configuration.md b/docs/configuration.md index b72d718..c5cea61 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -33,6 +33,10 @@ clipboard_timeout_seconds = 45 [security] inactivity_timeout_seconds = 120 +[git] +user_name = "Alice Example" +user_email = "alice@example.test" + [[git.remotes]] name = "origin" url = "https://git.example.test/alice/password-store.git" @@ -49,6 +53,10 @@ Commands are split into an executable and argument vector and are never passed through a shell. Only the CLI `edit` adapter may eventually launch this configured editor. +`git.user_name` and `git.user_email` set the author for commits created by +IronStorage. Both must be present together and cannot contain line breaks or +angle brackets. The built-in IronStorage identity is used when both are absent. + Git remotes are HTTPS-only. URLs containing user information, passwords, queries, or fragments are rejected. `server_id` and `application_id` are opaque references used to retrieve credentials from the operating-system secret