Configure iPhone Git commit identity (#76)

This commit is contained in:
2026-08-12 19:03:54 +02:00
parent e45ad07c89
commit 009db5cbcf
10 changed files with 392 additions and 37 deletions

View File

@@ -45,3 +45,12 @@ cargo test --workspace
For Apple project changes, also run `xcodegen generate` from `apple/` and build For Apple project changes, also run `xcodegen generate` from `apple/` and build
the affected simulator targets. 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.

View File

@@ -625,6 +625,8 @@ public protocol MobileAuthenticationProtocol: AnyObject, Sendable {
func generateEntryEditorPassword(editor: UInt64, length: UInt32?, noSymbols: Bool) throws -> MobileEntryEditorPage func generateEntryEditorPassword(editor: UInt64, length: UInt32?, noSymbols: Bool) throws -> MobileEntryEditorPage
func gitIdentity() throws -> MobileGitIdentity
func manualLock() throws func manualLock() throws
func performEntryMutation(request: MobileMutationRequest) throws -> MobileMutationOutcome func performEntryMutation(request: MobileMutationRequest) throws -> MobileMutationOutcome
@@ -643,6 +645,8 @@ public protocol MobileAuthenticationProtocol: AnyObject, Sendable {
func setBiometricUnlock(enabled: Bool) throws -> MobileAuthenticationState 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 setTotpWatchShared(path: String, shared: Bool, unixSeconds: UInt64) throws -> MobileTotpDetail
func state() throws -> MobileAuthenticationState 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) { open func manualLock()throws {try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
uniffiCallStatus in uniffiCallStatus in
uniffi_ironstorage_apple_fn_method_mobileauthentication_manual_lock( 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 { open func setTotpWatchShared(path: String, shared: Bool, unixSeconds: UInt64)throws -> MobileTotpDetail {
return try FfiConverterTypeMobileTotpDetail_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { return try FfiConverterTypeMobileTotpDetail_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
uniffiCallStatus in 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 struct MobileHomeChange: Equatable, Hashable {
public var id: String public var id: String
public var title: 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) { if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_generate_entry_editor_password() != 34289) {
return InitializationResult.apiChecksumMismatch 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) { if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_manual_lock() != 57220) {
return InitializationResult.apiChecksumMismatch return InitializationResult.apiChecksumMismatch
} }
@@ -6891,6 +6972,9 @@ private let initializationResult: InitializationResult = {
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_biometric_unlock() != 9486) { if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_biometric_unlock() != 9486) {
return InitializationResult.apiChecksumMismatch 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) { if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_totp_watch_shared() != 57472) {
return InitializationResult.apiChecksumMismatch return InitializationResult.apiChecksumMismatch
} }

View File

@@ -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 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 #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 #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_MANUAL_LOCK
#define 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 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 RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_set_biometric_unlock(uint64_t ptr, int8_t enabled, RustCallStatus *_Nonnull out_status
); );
#endif #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 #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 #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 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 #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 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 #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_MANUAL_LOCK #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 #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_biometric_unlock(void 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 #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_TOTP_WATCH_SHARED #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_TOTP_WATCH_SHARED

View File

@@ -712,6 +712,7 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
private let authentication: MobileAuthentication? private let authentication: MobileAuthentication?
private let keyTransfer = try? mobileKeyTransfer() private let keyTransfer = try? mobileKeyTransfer()
private var state: MobileAuthenticationState? private var state: MobileAuthenticationState?
private var gitIdentity: MobileGitIdentity?
private var preferenceTask: Task<Void, Never>? private var preferenceTask: Task<Void, Never>?
private var loadTask: Task<Void, Never>? private var loadTask: Task<Void, Never>?
private var loadGeneration = 0 private var loadGeneration = 0
@@ -753,14 +754,14 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
} }
override func numberOfSections(in tableView: UITableView) -> Int { override func numberOfSections(in tableView: UITableView) -> Int {
page.state == .ready ? 3 : 0 page.state == .ready ? 4 : 0
} }
override func tableView( override func tableView(
_ tableView: UITableView, _ tableView: UITableView,
numberOfRowsInSection section: Int numberOfRowsInSection section: Int
) -> Int { ) -> Int {
section == 0 ? 2 : (section == 1 ? 1 : 2) section == 1 ? 2 : (section == 3 ? 2 : 1)
} }
override func tableView( override func tableView(
@@ -768,8 +769,9 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
titleForHeaderInSection section: Int titleForHeaderInSection section: Int
) -> String? { ) -> String? {
switch section { switch section {
case 0: "GPG Key Transfer" case 0: "Git Commit Identity"
case 1: "Secure Unlock" case 1: "GPG Key Transfer"
case 2: "Secure Unlock"
default: "Authentication Session" default: "Authentication Session"
} }
} }
@@ -779,9 +781,12 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
titleForFooterInSection section: Int titleForFooterInSection section: Int
) -> String? { ) -> String? {
if section == 0 { 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 { 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 "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." 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) let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
var content = cell.defaultContentConfiguration() var content = cell.defaultContentConfiguration()
if indexPath.section == 0 { 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 let importing = indexPath.row == 0
content.image = UIImage(systemName: importing ? "qrcode.viewfinder" : "qrcode") content.image = UIImage(systemName: importing ? "qrcode.viewfinder" : "qrcode")
content.text = importing ? "Import GPG Key" : "Export GPG Key" content.text = importing ? "Import GPG Key" : "Export GPG Key"
@@ -803,7 +815,7 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
cell.accessoryType = .disclosureIndicator cell.accessoryType = .disclosureIndicator
cell.isUserInteractionEnabled = keyTransfer != nil cell.isUserInteractionEnabled = keyTransfer != nil
cell.contentView.alpha = keyTransfer == nil ? 0.45 : 1 cell.contentView.alpha = keyTransfer == nil ? 0.45 : 1
} else if indexPath.section == 1 { } else if indexPath.section == 2 {
content.image = UIImage(systemName: "faceid") content.image = UIImage(systemName: "faceid")
content.text = "Biometric Unlock" content.text = "Biometric Unlock"
content.secondaryText = state?.biometricUnlockEnabled == true content.secondaryText = state?.biometricUnlockEnabled == true
@@ -841,6 +853,10 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true) tableView.deselectRow(at: indexPath, animated: true)
if indexPath.section == 0 { if indexPath.section == 0 {
editGitIdentity()
return
}
if indexPath.section == 1 {
guard let keyTransfer else { return } guard let keyTransfer else { return }
if indexPath.row == 0 { if indexPath.row == 0 {
requestKeyScanner(keyTransfer) requestKeyScanner(keyTransfer)
@@ -852,7 +868,7 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
} }
return return
} }
guard indexPath.section == 2, indexPath.row == 1, let authentication else { return } guard indexPath.section == 3, indexPath.row == 1, let authentication else { return }
do { do {
try authentication.manualLock() try authentication.manualLock()
refreshState() refreshState()
@@ -970,6 +986,81 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
present(alert, animated: true) 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<MobileGitIdentity, AuthenticationFailure>.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() { @objc private func authenticationDidChange() {
refreshState() refreshState()
} }
@@ -1014,6 +1105,7 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
private func refreshState() { private func refreshState() {
state = try? authentication?.state() state = try? authentication?.state()
gitIdentity = try? authentication?.gitIdentity()
tableView.reloadData() tableView.reloadData()
} }
} }

View File

@@ -567,6 +567,12 @@ pub struct MobileAuthenticationState {
pub remaining_seconds: u64, 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)] #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
pub enum MobileMutationAction { pub enum MobileMutationAction {
Move, Move,
@@ -1328,6 +1334,30 @@ impl MobileAuthentication {
.map_err(Into::into) .map_err(Into::into)
} }
pub fn git_identity(&self) -> Result<MobileGitIdentity, MobileAuthenticationFfiError> {
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<MobileGitIdentity, MobileAuthenticationFfiError> {
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( pub fn unlock_entry(
&self, &self,
path: String, path: String,

View File

@@ -16,10 +16,13 @@ use cap_tempfile::TempFile;
use serde::Deserialize; use serde::Deserialize;
use url::Url; use url::Url;
use crate::authentication::{AuthenticationTimeout, DEFAULT_AUTHENTICATION_TIMEOUT}; use crate::{
use crate::mobile::MobileTab; authentication::{AuthenticationTimeout, DEFAULT_AUTHENTICATION_TIMEOUT},
use crate::presentation::{ClipboardTimeout, DEFAULT_CLIPBOARD_TIMEOUT}; git::GitIdentity,
use crate::repository::EntryPath; mobile::MobileTab,
presentation::{ClipboardTimeout, DEFAULT_CLIPBOARD_TIMEOUT},
repository::EntryPath,
};
const APPLICATION_DIRECTORY: &str = "ironstorage"; const APPLICATION_DIRECTORY: &str = "ironstorage";
const CONFIG_FILE: &str = "config.toml"; const CONFIG_FILE: &str = "config.toml";
@@ -40,6 +43,7 @@ pub struct Config {
mobile_tab: MobileTab, mobile_tab: MobileTab,
mobile_home_refreshed_at: Option<i64>, mobile_home_refreshed_at: Option<i64>,
watch_shared_totp_entries: BTreeSet<EntryPath>, watch_shared_totp_entries: BTreeSet<EntryPath>,
git_identity: GitIdentity,
git_remotes: Vec<GitRemote>, git_remotes: Vec<GitRemote>,
} }
@@ -145,6 +149,10 @@ impl Config {
&self.git_remotes &self.git_remotes
} }
pub fn git_identity(&self) -> &GitIdentity {
&self.git_identity
}
pub fn settings(&self) -> ConfigSettings { pub fn settings(&self) -> ConfigSettings {
let editor = self.editor.as_ref().map(|editor| { let editor = self.editor.as_ref().map(|editor| {
std::iter::once(editor.program.clone()) std::iter::once(editor.program.clone())
@@ -272,6 +280,35 @@ impl Config {
validate_config(self.source.clone(), document, raw)?.persist() 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::<RawConfig>()
.map_err(|_| ConfigError::Malformed {
path: self.source.clone(),
})?;
validate_config(self.source.clone(), document, raw)?.persist()
}
fn current_document(&self) -> Result<toml::Value, ConfigError> { fn current_document(&self) -> Result<toml::Value, ConfigError> {
Self::load(Some(&self.source)).map(|config| config.document) Self::load(Some(&self.source)).map(|config| config.document)
} }
@@ -930,6 +967,8 @@ enum RawEditor {
#[derive(Default, Deserialize)] #[derive(Default, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
struct RawGit { struct RawGit {
user_name: Option<String>,
user_email: Option<String>,
#[serde(default)] #[serde(default)]
remotes: Vec<RawGitRemote>, remotes: Vec<RawGitRemote>,
} }
@@ -1019,6 +1058,19 @@ fn validate_config(
}) })
}) })
.collect::<Result<BTreeSet<_>, _>>()?; .collect::<Result<BTreeSet<_>, _>>()?;
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)?; let git_remotes = validate_remotes(raw.git.remotes)?;
Ok(Config { Ok(Config {
@@ -1034,6 +1086,7 @@ fn validate_config(
mobile_tab, mobile_tab,
mobile_home_refreshed_at, mobile_home_refreshed_at,
watch_shared_totp_entries, watch_shared_totp_entries,
git_identity,
git_remotes, 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 { let git = git.as_table().ok_or_else(|| ConfigError::Malformed {
path: source.to_owned(), 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") { if let Some(remotes) = git.get("remotes") {
let remotes = remotes.as_array().ok_or_else(|| ConfigError::Malformed { let remotes = remotes.as_array().ok_or_else(|| ConfigError::Malformed {
path: source.to_owned(), path: source.to_owned(),

View File

@@ -190,6 +190,7 @@ struct ActiveMobileLease {
struct MobileAuthenticationStatus { struct MobileAuthenticationStatus {
biometric_unlock_enabled: bool, biometric_unlock_enabled: bool,
git_identity: GitIdentity,
active: Option<ActiveMobileLease>, active: Option<ActiveMobileLease>,
next_editor_id: u64, next_editor_id: u64,
editors: BTreeMap<u64, MobileEntryDraft>, editors: BTreeMap<u64, MobileEntryDraft>,
@@ -226,6 +227,7 @@ impl MobileAuthentication {
Ok(Self { Ok(Self {
status: Mutex::new(MobileAuthenticationStatus { status: Mutex::new(MobileAuthenticationStatus {
biometric_unlock_enabled: config.biometric_unlock_enabled(), biometric_unlock_enabled: config.biometric_unlock_enabled(),
git_identity: config.git_identity().clone(),
active: None, active: None,
next_editor_id: 0, next_editor_id: 0,
editors: BTreeMap::new(), editors: BTreeMap::new(),
@@ -394,6 +396,29 @@ impl MobileAuthentication {
}) })
} }
pub fn git_identity(&self) -> Result<GitIdentity, MobileAuthenticationError> {
Ok(self.status()?.git_identity.clone())
}
pub fn set_git_identity(
&self,
name: String,
email: String,
) -> Result<GitIdentity, MobileAuthenticationError> {
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> { pub fn touch_user_activity(&self) -> Result<(), MobileAuthenticationError> {
let status = self.status()?; let status = self.status()?;
let active = status.active.as_ref().ok_or_else(|| { let active = status.active.as_ref().ok_or_else(|| {
@@ -483,17 +508,18 @@ impl MobileAuthentication {
&self, &self,
request: MobileMutationRequest, request: MobileMutationRequest,
) -> Result<MobileMutationOutcome, MobileAuthenticationError> { ) -> Result<MobileMutationOutcome, MobileAuthenticationError> {
let (handle, key) = { let (handle, key, identity) = {
let status = self.status()?; let status = self.status()?;
let active = status.active.as_ref().ok_or_else(locked_error)?; 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( let mut committer =
&self.repository, AutomaticTreeCommitter::for_source(&self.repository, &request.source, identity)
&request.source, .map_err(git_mutation_error)?;
GitIdentity::ironstorage(),
)
.map_err(git_mutation_error)?;
let has_open_editor = self.editor_state(&request.source)?.0; let has_open_editor = self.editor_state(&request.source)?.0;
let editors = if has_open_editor && request.discard_editor { let editors = if has_open_editor && request.discard_editor {
self.take_entry_editors(&request.source)? self.take_entry_editors(&request.source)?
@@ -632,9 +658,9 @@ impl MobileAuthentication {
document document
.replace_field_value(EntryFieldId::from_value(field), value.into_bytes()) .replace_field_value(EntryFieldId::from_value(field), value.into_bytes())
.map_err(document_error)?; .map_err(document_error)?;
let mut committer = let identity = self.git_identity()?;
AutomaticEntryCommitter::for_entry(&self.repository, path, GitIdentity::ironstorage()) let mut committer = AutomaticEntryCommitter::for_entry(&self.repository, path, identity)
.map_err(|error| entry_detail("Password Entry Could Not Be Saved", error))?; .map_err(|error| entry_detail("Password Entry Could Not Be Saved", error))?;
EntryDocumentService::new(&self.repository, &self.keys) EntryDocumentService::new(&self.repository, &self.keys)
.save_recoverable(&document, None, &mut committer) .save_recoverable(&document, None, &mut committer)
.map_err(document_error)?; .map_err(document_error)?;
@@ -795,17 +821,15 @@ impl MobileAuthentication {
return Err(editor_error(error)); return Err(editor_error(error));
} }
let path = draft.document().path().to_string(); let path = draft.document().path().to_string();
let mut committer = match AutomaticEntryCommitter::for_entry( let identity = self.git_identity()?;
&self.repository, let mut committer =
&path, match AutomaticEntryCommitter::for_entry(&self.repository, &path, identity) {
GitIdentity::ironstorage(), Ok(committer) => committer,
) { Err(error) => {
Ok(committer) => committer, self.restore_editor(editor, draft)?;
Err(error) => { return Err(entry_detail("Password Entry Could Not Be Saved", 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) if let Err(error) = EntryDocumentService::new(&self.repository, &self.keys)
.save_recoverable(draft.document(), None, &mut committer) .save_recoverable(draft.document(), None, &mut committer)
{ {

View File

@@ -12,8 +12,8 @@ use std::{
use crate::{ use crate::{
config::{Config, ConfigError, GitRemote}, config::{Config, ConfigError, GitRemote},
git::{ git::{
GitChangeKind, GitCommitActivity, GitDivergence, GitError, GitIdentity, GitChangeKind, GitCommitActivity, GitDivergence, GitError, GitOperationControl,
GitOperationControl, GitProgressPhase, GitRepository, PullOutcome, GitProgressPhase, GitRepository, PullOutcome,
}, },
repository::{Repository, RepositoryError}, repository::{Repository, RepositoryError},
secret_store::{ secret_store::{
@@ -343,7 +343,7 @@ impl MobileHomeStorage {
let config = Config::load(None).map_err(MobileHomeError::from_config)?; let config = Config::load(None).map_err(MobileHomeError::from_config)?;
let repository = let repository =
Repository::open(config.vault()).map_err(MobileHomeError::from_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)?; .map_err(MobileHomeError::from_git)?;
let remote = config let remote = config
.git_remote(None) .git_remote(None)

View File

@@ -7,6 +7,7 @@ use ironstorage::{
authentication::{DEFAULT_AUTHENTICATION_TIMEOUT, MAX_AUTHENTICATION_TIMEOUT}, authentication::{DEFAULT_AUTHENTICATION_TIMEOUT, MAX_AUTHENTICATION_TIMEOUT},
config::{ConfigError, ConfigLoader, EditorSource}, config::{ConfigError, ConfigLoader, EditorSource},
desktop::DesktopStorage, desktop::DesktopStorage,
git::GitIdentity,
mobile::MobileTab, mobile::MobileTab,
repository::EntryPath, repository::EntryPath,
}; };
@@ -98,6 +99,38 @@ fn explicit_relative_configuration_resolves_deterministically() -> TestResult {
Ok(()) 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] #[test]
fn watch_totp_selection_persists_only_in_application_configuration() -> TestResult { fn watch_totp_selection_persists_only_in_application_configuration() -> TestResult {
let fixture = ConfigurationFixture::new()?; let fixture = ConfigurationFixture::new()?;

View File

@@ -33,6 +33,10 @@ clipboard_timeout_seconds = 45
[security] [security]
inactivity_timeout_seconds = 120 inactivity_timeout_seconds = 120
[git]
user_name = "Alice Example"
user_email = "alice@example.test"
[[git.remotes]] [[git.remotes]]
name = "origin" name = "origin"
url = "https://git.example.test/alice/password-store.git" 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 through a shell. Only the CLI `edit` adapter may eventually launch this
configured editor. 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, Git remotes are HTTPS-only. URLs containing user information, passwords,
queries, or fragments are rejected. `server_id` and `application_id` are opaque queries, or fragments are rejected. `server_id` and `application_id` are opaque
references used to retrieve credentials from the operating-system secret references used to retrieve credentials from the operating-system secret