* 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>
311 lines
10 KiB
JavaScript
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');
|