Implement Rust-owned Android local and HTTPS onboarding (#95)
This commit is contained in:
1 parent
66b1d4a4c4
commit
36e5f55283
46 files changed
+3002
-79
No files matched your search
@@ -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"
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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())
|
||||
{
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
Reference in new issue
Block a user