Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
423 changes: 390 additions & 33 deletions openless-all/app/android/kotlin/OpenLessCredentialVault.kt

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.openless.app

import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import java.io.File
import java.security.KeyStore
import java.util.UUID
import org.junit.After
Expand Down Expand Up @@ -32,6 +34,19 @@ class OpenLessCredentialVaultInstrumentedTest {
return response.copyOfRange(1, response.size)
}

private fun legacyVault() =
AndroidKeystoreCredentialVault("com.openless.app.credentials.v2")

private fun softwareStore(): SoftwareAesCredentialStore {
val filesDir = InstrumentationRegistry.getInstrumentation().targetContext.filesDir
return SoftwareAesCredentialStore(File(filesDir, "OpenLess"))
}

private fun resetFacadeState() {
OpenLessCredentialVault.deleteKey()
legacyVault().deleteKey()
}

@Test
fun roundTripUsesNonExportableKey() {
val plaintext = "instrumented credential".toByteArray()
Expand Down Expand Up @@ -66,6 +81,35 @@ class OpenLessCredentialVaultInstrumentedTest {
}
}

@Test
fun publicFacadeReadsBeta1V2Envelope() {
resetFacadeState()
try {
val plaintext = "beta1 credential".toByteArray()
val aad = "format-version-account".toByteArray()
val packet = payload(legacyVault().seal(plaintext, aad))

assertArrayEquals(plaintext, payload(OpenLessCredentialVault.open(packet, aad)))
} finally {
resetFacadeState()
}
}

@Test
fun softwareKeyCreatedBeforeEnvelopeCommitDoesNotHideV2Envelope() {
resetFacadeState()
try {
val plaintext = "still-v2 credential".toByteArray()
val aad = "format-version-account".toByteArray()
val packet = payload(legacyVault().seal(plaintext, aad))
payload(softwareStore().seal("discarded candidate".toByteArray(), aad))

assertArrayEquals(plaintext, payload(OpenLessCredentialVault.open(packet, aad)))
} finally {
resetFacadeState()
}
}

@Test
fun deletedKeyIsReportedAsMissing() {
val aad = "format-version-account".toByteArray()
Expand Down
101 changes: 101 additions & 0 deletions openless-all/app/android/kotlin/test/OpenLessCredentialCipherTest.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.openless.app

import java.io.File
import java.lang.reflect.Modifier
import java.security.GeneralSecurityException
import java.security.InvalidKeyException
import java.security.UnrecoverableKeyException
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
Expand Down Expand Up @@ -110,4 +112,103 @@ class OpenLessCredentialCipherTest {
credentialStatusForKeyLoadFailure(UnrecoverableKeyException("backend busy")),
)
}

@Test
fun invalidKeyExceptionIsTreatedAsUnrecoverable() {
assertEquals(
CREDENTIAL_STATUS_KEY_MISSING,
credentialStatusForCipherKeyFailure(InvalidKeyException("Keystore operation failed")),
)
}

@Test
fun softwareAesRoundTripWithoutAndroidKeyStore() {
val dir = File.createTempFile("ol-sw-aes", "dir")
assertTrue(dir.delete())
assertTrue(dir.mkdirs())
try {
val store = SoftwareAesCredentialStore(dir)
val plaintext = "credential-secret".toByteArray()
val aad = "format-version-account".toByteArray()
val sealed = store.seal(plaintext, aad)
assertEquals(CREDENTIAL_STATUS_OK, sealed.first())
val packet = sealed.copyOfRange(1, sealed.size)
val opened = store.open(packet, aad)
assertEquals(CREDENTIAL_STATUS_OK, opened.first())
assertArrayEquals(plaintext, opened.copyOfRange(1, opened.size))
assertTrue(File(dir, SoftwareAesCredentialStore.SOFTWARE_KEY_NAME).isFile)
assertFalse(store.isMigrated())
assertEquals(CREDENTIAL_STATUS_OK, store.markMigrated().first())
assertTrue(store.isMigrated())
} finally {
dir.deleteRecursively()
}
}

@Test
fun softwareAesMissingKeyIsReportedAsMissing() {
val dir = File.createTempFile("ol-sw-aes-missing", "dir")
assertTrue(dir.delete())
assertTrue(dir.mkdirs())
try {
val store = SoftwareAesCredentialStore(dir)
assertEquals(
CREDENTIAL_STATUS_KEY_MISSING,
store.open(byteArrayOf(12) + ByteArray(12 + 16), "aad".toByteArray()).first(),
)
} finally {
dir.deleteRecursively()
}
}

@Test
fun openFallbackPrefersSuccessAndNeverDowngradesARecoverableFailureToMissing() {
val success = byteArrayOf(CREDENTIAL_STATUS_OK, 7)
var afterSuccessCalled = false
assertArrayEquals(
success,
credentialOpenWithFallback(
{ byteArrayOf(CREDENTIAL_STATUS_AUTHENTICATION_FAILED) },
{ success },
{
afterSuccessCalled = true
byteArrayOf(CREDENTIAL_STATUS_OK, 8)
},
),
)
assertFalse(afterSuccessCalled)
assertEquals(
CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE,
credentialOpenWithFallback(
{ byteArrayOf(CREDENTIAL_STATUS_KEY_MISSING) },
{ byteArrayOf(CREDENTIAL_STATUS_AUTHENTICATION_FAILED) },
{ byteArrayOf(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE) },
)
.first(),
)
assertEquals(
CREDENTIAL_STATUS_AUTHENTICATION_FAILED,
credentialOpenWithFallback(
{ byteArrayOf(CREDENTIAL_STATUS_KEY_MISSING) },
{ byteArrayOf(CREDENTIAL_STATUS_AUTHENTICATION_FAILED) },
)
.first(),
)
assertEquals(
CREDENTIAL_STATUS_MALFORMED,
credentialOpenWithFallback(
{ byteArrayOf(CREDENTIAL_STATUS_KEY_MISSING) },
{ byteArrayOf(CREDENTIAL_STATUS_MALFORMED) },
)
.first(),
)
assertEquals(
CREDENTIAL_STATUS_KEY_MISSING,
credentialOpenWithFallback(
{ byteArrayOf(CREDENTIAL_STATUS_KEY_MISSING) },
{ byteArrayOf(CREDENTIAL_STATUS_KEY_MISSING) },
)
.first(),
)
}
}
91 changes: 86 additions & 5 deletions openless-all/app/crates/openless-core/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3037,11 +3037,23 @@ impl OpenLessBackend {
}

pub async fn start(&self) -> Result<StartupSnapshot, BackendError> {
let credentials = self
.deps
.credential_store
.status(self.get_preferences())
.await?;
let preferences = self.get_preferences();
let credentials = match self.deps.credential_store.status(preferences.clone()).await {
Ok(credentials) => credentials,
Err(error) if error.code == BackendErrorCode::Persistence => {
// Vault unreadable (e.g. Android Keystore temporarily unavailable)
// must not fail the 2.0 handshake. Dictation still gates on read().
log::warn!("[core] startup credential status unavailable: {error}");
CredentialsStatus {
pipeline_mode: crate::shared_types::effective_pipeline_mode(
preferences.multimodal_pipeline_enabled,
preferences.pipeline_mode,
),
..CredentialsStatus::default()
}
}
Err(error) => return Err(error),
};
let mut state = self.state.write().expect("backend state lock poisoned");
state.credentials = credentials;
if state.running {
Expand Down Expand Up @@ -8588,6 +8600,75 @@ mod tests {
);
}

struct PersistenceOnlyCredentialStore;

impl crate::credentials::CredentialStore for PersistenceOnlyCredentialStore {
fn status(
&self,
_preferences: crate::shared_types::UserPreferences,
) -> BoxFuture<'static, Result<CredentialsStatus, BackendError>> {
Box::pin(async {
Err(BackendError::new(
BackendErrorCode::Persistence,
"无法读取已保存的凭据:temporarily unavailable",
))
})
}

fn read(
&self,
_key: crate::credentials::CredentialKey,
) -> BoxFuture<'static, Result<Option<crate::credentials::SecretValue>, BackendError>>
{
Box::pin(async { Ok(None) })
}

fn write(
&self,
_key: crate::credentials::CredentialKey,
_value: crate::credentials::SecretValue,
) -> BoxFuture<'static, Result<(), BackendError>> {
Box::pin(async { Ok(()) })
}

fn remove(
&self,
_key: crate::credentials::CredentialKey,
) -> BoxFuture<'static, Result<(), BackendError>> {
Box::pin(async { Ok(()) })
}
}

#[tokio::test]
async fn start_survives_persistent_vault_read_failure() {
let data_dir = TestDataDir::new("vault-persistence-start");
let backend = OpenLessBackend::new(
BackendConfig {
data_dir: data_dir.path().to_path_buf(),
..BackendConfig::default()
},
BackendDependencies {
host_actions: Arc::new(FakeHost::default()),
text_inserter: Arc::new(FakeInserter),
dictation_engine: Arc::new(FakeEngine),
task_spawner: Arc::new(TokioTaskSpawner),
credential_store: Arc::new(PersistenceOnlyCredentialStore),
services: crate::domains::BackendServices::unsupported(),
local_asr_runtime: None,
marketplace_config: None,
selection_runtime: None,
selection_polisher: None,
qa_runtime: None,
},
)
.unwrap();
let first = backend.start().await.expect("first start must not fail");
let second = backend.start().await.expect("handshake start must not fail");
assert!(first.backend.running);
assert!(second.backend.running);
let _ = data_dir;
}

#[tokio::test]
async fn front_app_is_captured_without_reading_documents_when_cursor_context_is_disabled() {
let data_dir = TestDataDir::new("host-context-privacy");
Expand Down
37 changes: 36 additions & 1 deletion openless-all/app/linux-egui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1645,7 +1645,9 @@ mod linux_app {
ui.ctx().copy_text(display);
}
}
None => ui.label("完整指纹不可用。请勿安装或信任下载的证书。"),
None => {
ui.label("完整指纹不可用。请勿安装或信任下载的证书。");
}
}
ui.label("安装或开启完全信任前,在手机系统的证书详情中核对全部 SHA-256 字符,必须与此处一致。网页、描述文件名称和标识不能证明证书身份。若不一致或无法查看,请停止并移除已下载或安装的描述文件。");
ui.label("描述文件应只包含一张根证书。若有其他证书、VPN 或设备管理配置,请勿安装。首次下载仍可能被局域网攻击者替换;核验后再信任。根证书可签发其他证书,不再使用时请移除。");
Expand Down Expand Up @@ -3002,6 +3004,7 @@ mod linux_app {
port: 8443,
urls: vec!["https://old.example.invalid".into()],
urls_stale,
ca_fingerprint_sha256: None,
locale: "en".into(),
connection_count: 0,
active_session_id: None,
Expand All @@ -3015,6 +3018,38 @@ mod linux_app {
}
}

#[test]
fn running_remote_status_shows_ca_fingerprint_or_unavailable_warning() {
let mut app = disconnected_app();
let fingerprint = "ab".repeat(32);
app.remote_access = Some((
openless_core::RemoteInputStatus {
enabled: true,
running: true,
starting: false,
port: 8443,
urls: vec!["https://phone.example.invalid".into()],
urls_stale: false,
ca_fingerprint_sha256: Some(fingerprint.clone()),
locale: "zh-CN".into(),
connection_count: 0,
active_session_id: None,
},
"fixture-pin".into(),
));
let text = rendered_text(|ui| app.remote_ui(ui));
assert!(text.contains("本机根证书 SHA-256"), "{text}");
assert!(
text.contains("AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB"),
"{text}"
);
assert!(!text.contains("完整指纹不可用"), "{text}");

app.remote_access.as_mut().unwrap().0.ca_fingerprint_sha256 = None;
let text = rendered_text(|ui| app.remote_ui(ui));
assert!(text.contains("完整指纹不可用。请勿安装或信任下载的证书。"), "{text}");
}

#[test]
fn continuation_turn_keeps_receiving_output_and_approval() {
let mut app = OpenLessEguiApp::new(
Expand Down
Loading
Loading