Implement Rust-owned Android local and HTTPS onboarding (#95)
Android ARM verification / rust (push) Failing after 46m9s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Successful in 21s

This commit is contained in:
hugo committed 2026-10-09 11:31:50 +02:00
1 parent 66b1d4a4c4
commit 36e5f55283
46 files changed
+3002 -79

No files matched your search

+4
View File
@@ -14,3 +14,7 @@ crate-type = ["lib", "cdylib"]
[dependencies]
ironstorage-mobile = { path = "../mobile-bridge" }
[target.'cfg(target_os = "android")'.dependencies]
jni = "=0.22.4"
rustls-platform-verifier = "=0.7.0"
+3
View File
@@ -4,3 +4,6 @@
//! The shared component owns every conversion; no interface is copied here.
ironstorage_mobile::uniffi_reexport_scaffolding!();
#[cfg(target_os = "android")]
mod tls;
+17
View File
@@ -0,0 +1,17 @@
//! OS certificate-verifier initialization only; Git/TLS policy stays in storage.
//! The JNI macro supplies a scoped valid environment and catches boundary panics;
//! rustls retains global Context/class-loader references before this scope ends.
use jni::{Env, objects::JObject};
const _: jni::NativeMethod = jni::native_method! {
java_type = "de.rfc1437.ironstorage.bridge.NativeTls",
extern fn initialize(context: JObject),
};
fn initialize<'local>(
env: &mut Env<'local>,
_this: JObject<'local>,
context: JObject<'local>,
) -> Result<(), jni::errors::Error> {
rustls_platform_verifier::android::init_with_env(env, context)
}
@@ -0,0 +1,187 @@
//! Mechanical Android onboarding exports; all setup decisions remain in storage.
use crate::{
MobileOnboardingDiscovery, MobileOnboardingFfiError, MobileOnboardingOutcome,
MobileOnboardingProgress, MobilePrivateRuntime, MobileSecretBackend,
platform::SecretBackendAdapter,
};
use ironstorage::{
mobile_onboarding::{
MobileOnboardingRequest,
android::{AndroidOnboarding, AndroidOnboardingStatus},
},
repository::SecretBytes,
};
use std::sync::Arc;
#[derive(Clone, uniffi::Record)]
pub struct MobileAndroidSetupKey {
pub fingerprint: String,
pub user_ids: Vec<String>,
}
#[derive(Clone, uniffi::Record)]
pub struct MobileAndroidSetupStatus {
pub configured: bool,
pub connected: bool,
pub existing_clone: bool,
pub keys: Vec<MobileAndroidSetupKey>,
pub selected_key: Option<String>,
pub title: String,
pub detail: String,
}
impl From<AndroidOnboardingStatus> for MobileAndroidSetupStatus {
fn from(status: AndroidOnboardingStatus) -> Self {
Self {
title: status.title,
detail: status.detail,
configured: status.configured,
connected: status.connected,
existing_clone: status.existing_clone,
selected_key: status.selected_key,
keys: status
.keys
.into_iter()
.map(|key| MobileAndroidSetupKey {
fingerprint: key.fingerprint().as_str().into(),
user_ids: key.user_ids().to_vec(),
})
.collect(),
}
}
}
#[derive(uniffi::Object)]
pub struct MobileAndroidOnboarding {
setup: Arc<AndroidOnboarding>,
}
impl MobileAndroidOnboarding {
pub(crate) fn new(
runtime: &MobilePrivateRuntime,
backend: Arc<dyn MobileSecretBackend>,
) -> Result<Arc<Self>, MobileOnboardingFfiError> {
Ok(Arc::new(Self {
setup: Arc::new(AndroidOnboarding::new(
&runtime.storage,
Arc::new(SecretBackendAdapter(backend)),
)?),
}))
}
}
fn secret(value: Option<String>) -> Option<SecretBytes> {
value.map(|value| SecretBytes::new(value.into_bytes()))
}
fn outcome(
value: ironstorage::mobile_onboarding::MobileOnboardingOutcome,
) -> MobileOnboardingOutcome {
MobileOnboardingOutcome {
title: value.title().into(),
detail: value.detail().into(),
}
}
#[uniffi::export]
impl MobileAndroidOnboarding {
pub fn status(&self) -> Result<MobileAndroidSetupStatus, MobileOnboardingFfiError> {
Ok(self.setup.status()?.into())
}
pub fn generate(
&self,
user_id: String,
passphrase: String,
) -> Result<MobileAndroidSetupStatus, MobileOnboardingFfiError> {
Ok(self
.setup
.generate(&user_id, SecretBytes::new(passphrase.into_bytes()))?
.into())
}
pub fn import_key(
&self,
armor: String,
passphrase: Option<String>,
) -> Result<MobileAndroidSetupStatus, MobileOnboardingFfiError> {
Ok(self
.setup
.import(SecretBytes::new(armor.into_bytes()), secret(passphrase))?
.into())
}
pub fn setup_local(
&self,
fingerprint: String,
passphrase: Option<String>,
) -> Result<MobileOnboardingOutcome, MobileOnboardingFfiError> {
Ok(outcome(
self.setup.setup_local(&fingerprint, secret(passphrase))?,
))
}
pub fn remote(
&self,
server_url: String,
account: String,
repository_path: String,
application_token: String,
) -> Result<Arc<MobileAndroidRemoteOnboarding>, MobileOnboardingFfiError> {
Ok(Arc::new(MobileAndroidRemoteOnboarding {
setup: Arc::clone(&self.setup),
request: MobileOnboardingRequest::new(
server_url,
account,
repository_path,
application_token.into_bytes(),
)?,
}))
}
pub fn replace_token(
&self,
account: String,
application_token: String,
) -> Result<(), MobileOnboardingFfiError> {
Ok(self
.setup
.replace_token(account, SecretBytes::new(application_token.into_bytes()))?)
}
pub fn cancel(&self) {
self.setup.cancel();
}
pub fn progress(&self) -> MobileOnboardingProgress {
let progress = self.setup.progress();
MobileOnboardingProgress {
phase: progress.phase().into(),
title: progress.title().into(),
detail: progress.detail().into(),
}
}
}
#[derive(uniffi::Object)]
pub struct MobileAndroidRemoteOnboarding {
setup: Arc<AndroidOnboarding>,
request: MobileOnboardingRequest,
}
#[uniffi::export]
impl MobileAndroidRemoteOnboarding {
pub fn discover(&self) -> Result<MobileOnboardingDiscovery, MobileOnboardingFfiError> {
let value = self.setup.discover(&self.request)?;
Ok(MobileOnboardingDiscovery {
branches: value.branches().to_vec(),
selected_branch: value.selected_branch() as u32,
})
}
pub fn clone_store(
&self,
branch: String,
fingerprint: String,
passphrase: Option<String>,
use_existing: bool,
) -> Result<MobileOnboardingOutcome, MobileOnboardingFfiError> {
Ok(outcome(self.setup.clone_store(
&self.request,
&branch,
&fingerprint,
secret(passphrase),
use_existing,
)?))
}
pub fn connect(&self) -> Result<MobileOnboardingOutcome, MobileOnboardingFfiError> {
Ok(outcome(self.setup.connect(&self.request)?))
}
pub fn cancel(&self) {
self.setup.cancel();
}
}
+2
View File
@@ -81,6 +81,8 @@ uniffi::setup_scaffolding!();
mod android_keystore;
pub use android_keystore::*;
mod android_onboarding;
pub use android_onboarding::*;
mod platform;
pub use platform::*;
+7 -1
View File
@@ -96,10 +96,16 @@ pub struct MobilePrivateStorageLayout {
#[derive(uniffi::Object)]
pub struct MobilePrivateRuntime {
storage: PrivateStorage,
pub(crate) storage: PrivateStorage,
}
#[uniffi::export]
impl MobilePrivateRuntime {
pub fn android_onboarding(
&self,
backend: Arc<dyn MobileSecretBackend>,
) -> Result<Arc<crate::MobileAndroidOnboarding>, crate::MobileOnboardingFfiError> {
crate::MobileAndroidOnboarding::new(self, backend)
}
pub fn android_secret_backend(
&self,
keystore: Arc<dyn crate::MobileAndroidKeystore>,
+25
View File
@@ -266,6 +266,31 @@ impl KeyStore {
Ok(store)
}
/// Merge prepared key material while retaining existing private keys.
pub(crate) fn extend_from_path(&mut self, path: &Path) -> Result<(), CryptoError> {
let loaded = match Self::load(path) {
Ok(loaded) => loaded,
Err(CryptoError::NoKeyMaterial) => return Ok(()),
Err(error) => return Err(error),
};
if self.keys.len() + loaded.keys.len() > MAX_IMPORTED_KEYS {
return Err(CryptoError::TooManyKeys);
}
for (fingerprint, material) in loaded.keys {
if let Some(existing) = self.keys.get_mut(&fingerprint) {
if existing.public.primary_key != material.public.primary_key {
return Err(CryptoError::DuplicateFingerprint { fingerprint });
}
if existing.secret.is_none() {
existing.secret = material.secret;
}
} else {
self.keys.insert(fingerprint, material);
}
}
Ok(())
}
/// Transactionally import one armored or binary stream containing public and/or secret keys.
pub fn import(&mut self, bytes: &[u8]) -> Result<Vec<KeyInfo>, CryptoError> {
if bytes.len() as u64 > MAX_KEY_FILE_BYTES {
+44 -5
View File
@@ -1466,6 +1466,50 @@ impl GitRepository {
credentials: &impl GitRemoteCredentialProvider,
transport: &impl GitFetchTransport,
control: &GitOperationControl,
) -> Result<Vec<String>, GitError> {
let branches = Self::probe_remote_with_transport(
parent,
identity,
configured,
credentials,
transport,
control,
)?;
if branches.is_empty() {
return Err(GitError::InvalidRepository(
"the remote has no branches".to_owned(),
));
}
Ok(branches)
}
/// Validate HTTPS authentication before saving onboarding state, including an empty remote.
pub(crate) fn validate_remote_with_transport(
parent: &Path,
identity: GitIdentity,
configured: &GitRemote,
credentials: &impl GitRemoteCredentialProvider,
transport: &impl GitFetchTransport,
control: &GitOperationControl,
) -> Result<(), GitError> {
Self::probe_remote_with_transport(
parent,
identity,
configured,
credentials,
transport,
control,
)
.map(|_| ())
}
fn probe_remote_with_transport(
parent: &Path,
identity: GitIdentity,
configured: &GitRemote,
credentials: &impl GitRemoteCredentialProvider,
transport: &impl GitFetchTransport,
control: &GitOperationControl,
) -> Result<Vec<String>, GitError> {
control.report(GitProgressPhase::Validating)?;
ensure_remote_transport_available(configured.endpoint())?;
@@ -1648,11 +1692,6 @@ impl GitRepository {
.collect::<Result<Vec<_>, GitError>>()?;
branches.sort();
branches.dedup();
if branches.is_empty() {
return Err(GitError::InvalidRepository(
"the remote has no branches".to_owned(),
));
}
Ok(branches)
}
+2
View File
@@ -1,5 +1,7 @@
//! Storage-owned first-run setup for the native mobile applications.
pub mod android;
use std::{
error::Error,
fmt, fs,
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -532,7 +532,7 @@ fn regular_bytes(
}
Ok(Some(bytes))
}
fn exclusive_lock(parent: &Dir, name: &str) -> Result<fs::File, PrivateStorageError> {
pub(crate) fn exclusive_lock(parent: &Dir, name: &str) -> Result<fs::File, PrivateStorageError> {
if let Ok(metadata) = parent.symlink_metadata(name)
&& (metadata.file_type().is_symlink() || !metadata.is_file())
{
+38 -2
View File
@@ -1010,6 +1010,32 @@ struct SecretRecord {
fn encode_record(
reference: &SecretReference,
value: &SecretBytes,
) -> Result<SecretBytes, SecretStoreError> {
encode_record_with_owner(reference, value, None)
}
/// A unique protected owner distinguishes newly created credentials during crash rollback.
pub(crate) fn encode_onboarding_record(
reference: &SecretReference,
value: &SecretBytes,
owner: &[u8],
) -> Result<SecretBytes, SecretStoreError> {
if owner.is_empty()
|| owner.len() > 64
|| !matches!(
reference.kind,
SecretReferenceKind::HttpsGitCredential { .. }
)
{
return Err(SecretStoreError::InvalidReference);
}
encode_record_with_owner(reference, value, Some(owner))
}
fn encode_record_with_owner(
reference: &SecretReference,
value: &SecretBytes,
owner: Option<&[u8]>,
) -> Result<SecretBytes, SecretStoreError> {
validate_secret(value)?;
let mut encoded = Vec::with_capacity(RECORD_MAGIC.len() + value.expose().len() + 1024);
@@ -1025,7 +1051,10 @@ fn encode_record(
application_id,
account,
} => {
encoded.push(2);
encoded.push(if owner.is_some() { 4 } else { 2 });
if let Some(owner) = owner {
write_field(&mut encoded, owner)?;
}
write_field(&mut encoded, server_id.as_bytes())?;
write_field(&mut encoded, application_id.as_bytes())?;
write_field(&mut encoded, account.as_bytes())?;
@@ -1063,7 +1092,14 @@ fn decode_record(encoded: SecretBytes) -> Result<SecretRecord, SecretStoreError>
SecretReference::openpgp_passphrase(read_text(fingerprint)?)
.map_err(|_| SecretStoreError::Corrupted)?
}
2 => {
2 | 4 => {
if kind == 4 {
let (owner, rest) = read_field(remainder)?;
if owner.is_empty() || owner.len() > 64 {
return Err(SecretStoreError::Corrupted);
}
remainder = rest;
}
let (server_id, rest) = read_field(remainder)?;
let (application_id, rest) = read_field(rest)?;
let (account, rest) = read_field(rest)?;