Files
openless/openless-all/app/scripts/android-credential-keystore-contract.test.mjs
82357e8df3 fix(android): 对齐应用内更新 minisign 并修复凭据 vault (#1061)
* fix(android): parse Tauri-wrapped minisign keys so in-app APK install can open

Android updater called PublicKey::from_base64 on the wrapped tauri.conf pubkey, so install died before installApk. Also keep vault envelope failures from looking like an unconfigured Volcengine ASR provider.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(linux-egui): 补齐根证书指纹字段并让 fcitx5 门禁匹配实际安装调用

Release Linux egui 在 beta 上因 RemoteInputStatus 缺字段、match 臂类型不一致,以及门禁要求 ensure_fcitx5_ready(?) 而失败;安装调用本身已在监听器之前,错误会展示在 UI 里。

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(android): Keystore 暂时不可用时不要让 Tauri setup abort

Core.start() 会走 configuration_snapshot → load_credentials_for_update;信封读失败若返回 Err,setup hook 直接 SIGABRT。改为记录 last_read_error 并返回未缓存默认值,应用可以启动。

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(android): 凭据信封暂时读失败时不要挡住 2.0 启动握手

Core.start() 在 setup 成功后还会被 event bridge 和 get_startup_snapshot 再调一次;此时 last_read_error 已记下,require_readable_vault 把 Persistence 抛成「暂时无法启动」。握手改为降级空凭据状态继续运行。

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(android): 创建渠道时重试 Keystore,失败不覆盖空信封

启动时信封暂时读失败后,require_readable_vault 会在真正 load 之前短路,导致添加提供商一直操作失败。改为每次读写都重试 Keystore;读失败返回 Persistence 且不缓存/不写入默认空根;前端展示后端具体错误。

Co-authored-by: Cursor <cursoragent@cursor.com>

* debug(android): 把 Keystore 暂时不可用的具体异常名带回日志

添加提供商已会重试信封,但 Keystore 仍返回 temporarily unavailable。JNI 原先丢掉 Kotlin 异常与 JNI 失败路径,无法区分 class load、status 3 还是 BackendBusy。

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(android): 无法用于 Cipher 的 Keystore 密钥按失效信封恢复

真机 open 稳定抛出 InvalidKeyException(非 UserNotAuthenticated)。继续当成 temporarily unavailable 会让已保存凭据永远读不出来。改为走密钥缺失路径,删除无法解密的信封后可以重新配置提供商。

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(android): 凭据 Keystore 写入改走主线程并换用 v3 别名

清掉失效信封后,增加提供商仍在 seal 上失败(ProviderException/KeyStoreException)。HyperOS 上 Keystore2 从 JNI 后台线程调用常失败;改为主线程执行,并换用未损坏的 v3 别名,失败时删除别名再生成一次。

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(android): KeyMint AES-GCM 失败时回退到应用私有软件密钥

HyperOS 上 seal 稳定返回 KeyStoreException 10,主线程与 v3 别名仍无法写信封。Keystore 不可用时改用 UID 私有软件 AES-GCM,避免增加提供商失败。

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(android): preserve credentials across key fallback

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Chris233 <h-chris233@outlook.com>
2026-09-13 01:58:26 +08:00

311 lines
10 KiB
JavaScript

import { access, readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
const repoRoot = fileURLToPath(new URL('../../..', import.meta.url));
const paths = {
cipher: new URL('../android/kotlin/OpenLessCredentialCipher.kt', import.meta.url),
vault: new URL('../android/kotlin/OpenLessCredentialVault.kt', import.meta.url),
unitTest: new URL('../android/kotlin/test/OpenLessCredentialCipherTest.kt', import.meta.url),
instrumentedTest: new URL(
'../android/kotlin/androidTest/OpenLessCredentialVaultInstrumentedTest.kt',
import.meta.url,
),
rustStore: new URL('../src-tauri/src/persistence/android_credentials.rs', import.meta.url),
credentials: new URL('../src-tauri/src/persistence/credentials.rs', import.meta.url),
jni: new URL('../src-tauri/src/android/jni.rs', import.meta.url),
credentialCommands: new URL('../src-tauri/src/commands/credentials.rs', import.meta.url),
coreApi: new URL('../crates/openless-core/src/api.rs', import.meta.url),
mobileRuntime: new URL('../src-tauri/src/mobile_runtime.rs', import.meta.url),
channelList: new URL('../src/pages/settings/ChannelList.tsx', import.meta.url),
copyScript: new URL('./copy-android-scaffolding.mjs', import.meta.url),
ci: new URL('../../../.github/workflows/ci.yml', import.meta.url),
};
function display(url) {
return fileURLToPath(url).replace(`${repoRoot}/`, '');
}
async function requiredSource(name, url) {
try {
await access(url);
} catch {
throw new Error(`missing ${name}: ${display(url)}`);
}
return readFile(url, 'utf8');
}
function requirePattern(source, pattern, message) {
if (!pattern.test(source)) {
throw new Error(message);
}
}
const [
cipher,
vault,
unitTest,
instrumentedTest,
rustStore,
credentials,
jni,
credentialCommands,
coreApi,
mobileRuntime,
channelList,
copyScript,
ci,
] = await Promise.all([
requiredSource('pure AES-GCM codec', paths.cipher),
requiredSource('Android Keystore bridge', paths.vault),
requiredSource('JVM cipher tests', paths.unitTest),
requiredSource('Android Keystore instrumentation tests', paths.instrumentedTest),
requiredSource('Rust Android credential store', paths.rustStore),
requiredSource('credentials integration', paths.credentials),
requiredSource('JNI bridge', paths.jni),
requiredSource('credential commands', paths.credentialCommands),
requiredSource('Core API', paths.coreApi),
requiredSource('mobile runtime', paths.mobileRuntime),
requiredSource('channel list', paths.channelList),
requiredSource('Android scaffolding copier', paths.copyScript),
requiredSource('PR CI workflow', paths.ci),
]);
requirePattern(cipher, /AES\/GCM\/NoPadding/, 'cipher must use AES/GCM/NoPadding');
requirePattern(cipher, /NONCE_BYTES\s*=\s*12/, 'cipher must require a 12-byte nonce');
requirePattern(cipher, /TAG_BITS\s*=\s*128/, 'cipher must use a 128-bit GCM tag');
requirePattern(cipher, /updateAAD/, 'cipher must authenticate caller-provided AAD');
for (const pattern of [
/AndroidKeyStore/,
/setBlockModes\([^)]*BLOCK_MODE_GCM/,
/setEncryptionPaddings\([^)]*ENCRYPTION_PADDING_NONE/,
/setKeySize\(256\)/,
/setRandomizedEncryptionRequired\(true\)/,
/fun\s+seal\s*\(/,
/fun\s+open\s*\(/,
/fun\s+deleteKey\s*\(/,
/fun\s+migrationComplete\s*\(/,
/fun\s+markMigrationComplete\s*\(/,
]) {
requirePattern(vault, pattern, `Keystore bridge is missing ${pattern}`);
}
if (
/catch\s*\([^:]+:\s*UnrecoverableKeyException\)\s*\{\s*credentialResponse\(CREDENTIAL_STATUS_KEY_MISSING\)/.test(
vault,
)
) {
throw new Error('UnrecoverableKeyException must never trigger destructive key-missing cleanup');
}
for (const pattern of [
/is\s+KeyPermanentlyInvalidatedException\s*->\s*CREDENTIAL_STATUS_KEY_MISSING/,
/else\s*->\s*CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE/,
/fun\s+credentialStatusForCipherKeyFailure/,
/is\s+UserNotAuthenticatedException\s*->\s*CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE/,
/com\.openless\.app\.credentials\.v3/,
/com\.openless\.app\.credentials\.v2/,
/runOnMain/,
/class SoftwareAesCredentialStore/,
/credentials\.sw\.key/,
/SecretKeySpec/,
/FileOutputStream/,
/\.fd\.sync\(\)/,
]) {
requirePattern(vault, pattern, `Keystore failure classifier is missing ${pattern}`);
}
if (/\b(?:Log\.|println\s*\()/.test(vault)) {
throw new Error('Keystore bridge must not log secret-bearing inputs or crypto exceptions');
}
for (const pattern of [
/roundTrip/,
/freshNonce/,
/tamperedNonce/,
/tamperedCiphertext/,
/tamperedAad/,
/unrecoverableKeyExceptionRemainsRetryable/,
/invalidKeyExceptionIsTreatedAsUnrecoverable/,
/softwareAesRoundTripWithoutAndroidKeyStore/,
/softwareAesMissingKeyIsReportedAsMissing/,
/openFallbackPrefersSuccessAndNeverDowngradesARecoverableFailureToMissing/,
]) {
requirePattern(unitTest, pattern, `JVM crypto tests are missing ${pattern}`);
}
for (const pattern of [
/assertNull\([^)]*\.encoded/,
/deletedKey/,
/tamperedCiphertext/,
/publicFacadeReadsBeta1V2Envelope/,
/softwareKeyCreatedBeforeEnvelopeCommitDoesNotHideV2Envelope/,
]) {
requirePattern(
instrumentedTest,
pattern,
`Android Keystore instrumentation tests are missing ${pattern}`,
);
}
for (const pattern of [/recreate\s*=\s*true/, /deleteEntryQuiet/]) {
if (pattern.test(vault)) {
throw new Error(`credential sealing must not destructively rotate a live key: ${pattern}`);
}
}
const diagnosticSources = [
vault,
rustStore,
credentials,
jni,
credentialCommands,
coreApi,
mobileRuntime,
channelList,
].join('\n');
for (const pattern of [
/#region agent log/,
/\[agent-dbg\]/,
/f73b06/,
/hypothesisId/,
/debug-f73b06\.log/,
/127\.0\.0\.1:7807/,
]) {
if (pattern.test(diagnosticSources)) {
throw new Error(`one-off agent diagnostic must not ship: ${pattern}`);
}
}
for (const pattern of [
/openless-android-credentials/,
/version:\s*u32/,
/account:\s*String/,
/nonce:\s*String/,
/ciphertext:\s*String/,
/serde\(deny_unknown_fields\)/,
/KeyMissingOrInvalidated/,
/AuthenticationFailed/,
/TemporarilyUnavailable/,
/migration_complete/,
/mark_migration_complete/,
/recover_verified_sanitized_legacy/,
/recover_verified_v2_temporary/,
/mode\(0o600\)/,
/sync_all\(\)/,
]) {
requirePattern(rustStore, pattern, `Rust v2 store is missing ${pattern}`);
}
if (/Stub:\s*base64 envelope/.test(credentials)) {
throw new Error('legacy Base64 stub is still the Android credential writer');
}
for (const pattern of [
/keystore_seal/,
/keystore_open/,
/keystore_delete_key/,
/keystore_migration_complete/,
/keystore_mark_migration_complete/,
/JByteArray/,
]) {
requirePattern(jni, pattern, `JNI bridge is missing ${pattern}`);
}
requirePattern(
jni,
/fn\s+app_files_dir\s*\([\s\S]*?getFilesDir[\s\S]*?getAbsolutePath/,
'JNI bridge must resolve the app-private Context files directory',
);
requirePattern(
jni,
/fn\s+with_tao_android_env[\s\S]*?main_android_context/,
"startup persistence must use Tao's non-panicking Android context registry",
);
const androidCredentialPath = credentials.match(
/fn\s+android_credentials_path\s*\([^)]*\)\s*->\s*Result<PathBuf>[\s\S]*?\r?\n}\r?\n/,
);
if (!androidCredentialPath) {
throw new Error('missing Android credential path resolver');
}
requirePattern(
androidCredentialPath[0],
/app_files_dir\(\)[\s\S]*?join\("OpenLess"\)[\s\S]*?join\(ANDROID_CREDENTIALS_FILE\)/,
'Android credentials must use the app-private files directory',
);
if (/TAURI_ANDROID_APP_DATA_DIR|temp_dir/.test(androidCredentialPath[0])) {
throw new Error(
'Android credential storage must not fall back to environment or temporary storage',
);
}
for (const pattern of [
/android_legacy_credentials_paths/,
/load_android_credentials_from_source_with_crypto/,
/remove_migrated_android_legacy_credentials/,
/android_legacy_root_migrates_to_private_destination_and_is_erased/,
]) {
requirePattern(credentials, pattern, `Android credential root migration is missing ${pattern}`);
}
requirePattern(
rustStore,
/let verified = open_envelope\(&persisted, crypto\)\?;[\s\S]*?if verified != plaintext \{[\s\S]*?\}[\s\S]*?mark_migration_complete\(\)[\s\S]*?fault\(WriteStage::AfterVerification\)/,
'legacy downgrade barrier must be durable before the v2 envelope is installed',
);
requirePattern(
rustStore,
/verified_v2_commit_barrier_recovers_verified_temp_after_pre_rename_failure/,
'Rust store must recover the verified pre-rename v2 candidate without accepting legacy',
);
requirePattern(
rustStore,
/invalidated_key_clears_pending_v2_recovery_candidate/,
'Rust store must clear an unrecoverable pending v2 candidate so credentials can be reconfigured',
);
for (const file of [
'OpenLessCredentialCipher.kt',
'OpenLessCredentialVault.kt',
'OpenLessCredentialCipherTest.kt',
'OpenLessCredentialVaultInstrumentedTest.kt',
]) {
if (!copyScript.includes(file)) {
throw new Error(`Android scaffolding does not copy ${file}`);
}
}
requirePattern(
copyScript,
/testInstrumentationRunner[\s\S]*androidx\.test\.runner\.AndroidJUnitRunner/,
'generated Android project must declare the AndroidX instrumentation runner',
);
requirePattern(
ci,
/targets:\s*aarch64-linux-android,x86_64-linux-android/,
'PR CI must install the Rust target used by the x86_64 emulator',
);
requirePattern(
ci,
/testX86_64DebugUnitTest/,
'PR CI must execute JVM credential tests for the x86_64 flavor',
);
requirePattern(
ci,
/assembleX86_64DebugAndroidTest/,
'PR CI must compile x86_64 instrumentation tests',
);
requirePattern(
ci,
/connectedX86_64DebugAndroidTest/,
'PR CI must execute x86_64 Android Keystore instrumentation tests on a device',
);
if (/:app:(?:testDebugUnitTest|assembleDebugAndroidTest|connectedDebugAndroidTest)\b/.test(ci)) {
throw new Error('PR CI must not use Android tasks without the required ABI flavor');
}
requirePattern(
rustStore,
/successful_v2_rejects_legacy_base64_downgrade/,
'Rust store must test that legacy migration closes after v2 succeeds',
);
requirePattern(
credentials,
/android_bearer_is_scrubbed_before_failed_keystore_migration_returns/,
'credentials integration must preserve the fail-closed Marketplace bearer scrub',
);
console.log('android-credential-keystore-contract.test.mjs passed');