Skip to content

Commit 58bb117

Browse files
committed
Prepare guarded Android Keystore runtime acceptance
1 parent b4c2beb commit 58bb117

10 files changed

Lines changed: 516 additions & 23 deletions

File tree

‎STATE.md‎

Lines changed: 77 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,34 @@
11
# VMAnyware — current state
22

3-
Updated: 2026-10-22. Historical evidence and decisions are in Git and docs.
3+
Updated: 2026-10-23. Historical evidence and decisions are in Git and docs.
44

55
Naming note: historical lab resource identifiers below are normalized to the
66
current VMAnyware name. External resources were not renamed; use VMIDs and
77
ignored local lab records to verify actual identifiers before operations.
88

99
## Latest resume point
1010

11-
User-requested session checkpoint: finish this slice, commit/push and stop here.
12-
ADR 0088 implements Android Keystore wrapping for the optional application SSH
13-
unlock secret. ARM64/x86_64 Rust, Kotlin/JNI and both signed debug APKs compile/package;
14-
helper presence, native hashes, descriptors, initializer exports and existing
15-
manifest constraints pass. A focused scope test and affected browser journey pass.
16-
This is compiled/packaged implementation, not actual Android runtime acceptance.
17-
The SSH private key stays in its passphrase-encrypted app file. Linux Secret Service
18-
already has joined native UI/vault/SSH acceptance (ADR 0087). macOS Keychain,
19-
Windows/iOS parity, exact APK/device/Keystore behavior, real Proxmox and deployment
20-
acceptance remain open. No full-roadmap completion or audited percentage is claimed.
21-
22-
Resume next with an explicitly approved owned Android runtime: save/read/restart,
23-
tamper/wrong-scope and missing/invalidated wrapping-key refusal, forget/read refusal,
24-
then host-trust-first SSH/PTY using the system source. Do not start a VM, AVD or
25-
personal credential operation just because source/build checks pass. All task
26-
processes are terminal; no pending build or test needs restarting.
11+
Goal resumed 2026-10-23. Android Keystore production source and both debug APKs
12+
compile/package (ADR 0088). ADR 0089 now prepares a separate test-only instrumented
13+
APK (four real-Keystore cases), an exact-artifact read-only plan and the packaged
14+
IPC save/read/restart/forget scenario. Three host-only fake-ADB refusal checks pass.
15+
The test APK compiles; signatures, target/runner/test-only metadata and matching
16+
main/test signer verify. No runtime case has been executed. No emulator was created,
17+
started, installed/reset or contacted; no personal credential was read/changed.
18+
19+
Reviewable plan: `.lab/android-keystore/plan.json`, SHA-256
20+
`47d7cc257434b038f7be1b62b8f8f5f4635565ef20acb63e9bd976c41b4798bf`.
21+
It requires the named already booted x86_64 QEMU AVD, serial emulator-5580, ADB 5047,
22+
installs only the two hashed APKs and resets only dev.vmanyware.client. Proposed
23+
creation/start uses a fresh owned API-35/Pixel-6 AVD in the task-local Android user
24+
directory, 2 vCPUs/2048 MiB RAM/2 GiB data. These VM/app operations still require
25+
explicit concrete approval under AGENTS.md. No live task handle remains pending.
26+
Next: obtain that approval, then execute the exact plan and record actual runtime
27+
results; otherwise continue other source work without starting a VM.
28+
29+
The full roadmap remains active/incomplete: real Proxmox, Android runtime/SSH/IME,
30+
other platform stores/parity, deployment and release gates remain open. No audited
31+
completion percentage is claimed.
2732

2833

2934
Latest source slice: production HTTP admission now has a global burst-120,
@@ -6902,3 +6907,58 @@ then continue other source/platform work. User asked to stop at this checkpoint;
69026907
do not start another slice in this session. Website remains at the accepted Linux
69036908
checkpoint `11dcb6c`; Android source/package evidence is now in this repo and should
69046909
be synchronized on the next publication pass, without claiming device validation.
6910+
6911+
6912+
## 2026-10-23 — Prepared owned Android Keystore acceptance (ADR 0089)
6913+
6914+
Scope/acceptance: compile an isolated test APK and prepare a reviewed, opt-in
6915+
save/read/process-restart/forget plus negative Keystore scenario; enforce target/
6916+
artifact/reset gates without launching a VM or reading personal credentials.
6917+
Added four AndroidJUnit4 instrumented cases using only generated random scopes,
6918+
explicit owned-fixture argument and emulator hardware/package guards. Tests exercise
6919+
roundtrip/reopened helper/forget, authenticated ciphertext tamper/wrong scope,
6920+
wrapping-key loss with no silent regeneration and explicit forget/resave, malformed
6921+
scope/version/size and byte-array clearing. They add no production corruption/key
6922+
hook, do not enumerate aliases and clean only their own generated scopes.
6923+
6924+
The native Android harness gains --keystore with a mandatory local APK whose digest
6925+
must match the installed standalone APK before data reset. Actual IPC scenario
6926+
requires explicit save/forget confirmation, correct passphrase, unchanged public
6927+
identity, system unlock before/after process restart, forgotten-secret read refusal
6928+
and preserved manual identity. The separate planner verifies packages/signatures,
6929+
shared signer, main x86_64 ABI and test-only instrumentation target, hashes APKs and
6930+
host controllers, and records exact target/operations. Execution rechecks the plan
6931+
before ADB and checks AVD name/QEMU/boot/ABI before any installation. It requires all
6932+
four instrumented cases and matching native report; it removes only the test APK it
6933+
installed and stops the owned app. It does not create/start an emulator.
6934+
6935+
Verification: the targeted test APK assembles successfully
6936+
(`.lab/android-toolchain/keystore-test-apk-build.log`). Initial direct Gradle attempt
6937+
reached the unchanged native Rust hook needing a live Tauri CLI options server;
6938+
correct targeted build excludes :app:rustBuildX86_64Debug and reuses the existing
6939+
ADR-0088 verified library. Main Rust/Kotlin crypto source is unchanged. AndroidTest
6940+
uses pinned JUnit 1.3.0/runner 1.7.0; the unused generated Espresso dependency is
6941+
removed. A manifest now explicitly marks the artifact testOnly=true/allowBackup=false;
6942+
its absence was caught/refused by the first plan attempt before any ADB operation.
6943+
6944+
The read-only final plan passes SDK signature/manifest/ABI/signer/hash checks
6945+
(`.lab/android-toolchain/keystore-plan-check.log`). Main APK SHA-256 remains
6946+
`0aa58facc6a8d659e76f64cdcc738c44ac2072ddac55c3429c9643d3a072ab47`.
6947+
Test APK is 400436 bytes, SHA-256
6948+
`9f5447e94d2123d2686d05afa1d26a7d0fc196b6273cab96b2d1be1ec1ba264a`.
6949+
Plan SHA-256 is `47d7cc257434b038f7be1b62b8f8f5f4635565ef20acb63e9bd976c41b4798bf`.
6950+
The three host-only refusal cases pass (`.lab/android-keystore/host-guards.log`):
6951+
missing artifact causes zero fake-ADB calls, wrong AVD and changed installed APK
6952+
cause only guarded reads and no reset/install/force-stop. Python/JS syntax, diff
6953+
review and unchanged IPC manifest consistency (82 commands) pass. No unrelated
6954+
server/browser/Rust suite or production APK rebuild reran for this test-only slice.
6955+
6956+
Read-only SDK inspection confirms an API-35 Google APIs x86_64 image and Pixel-6
6957+
definition are already available; configured owned AVD listing is empty. This is
6958+
not VM creation/start authorization. Proposed setup and exact app/test operations
6959+
are reviewable in the plan and experiments/android-native/README.md. Runtime
6960+
approval remains pending; no ADB call against an emulator, installation, credential
6961+
change, real VM or provider access occurred. Four cases are compiled, not passed.
6962+
The plan report retains runtimeValidated=false and full APK/platform acceptance is
6963+
not changed. Next task: execute on the explicitly approved disposable runtime, then
6964+
record actual failures/success and continue Android system-source SSH/rotation.

‎apps/client/src-tauri/gen/android/app/build.gradle.kts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ android {
2020
defaultConfig {
2121
manifestPlaceholders["usesCleartextTraffic"] = "false"
2222
applicationId = "dev.vmanyware.client"
23+
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
2324
minSdk = 24
2425
targetSdk = 36
2526
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
@@ -93,8 +94,8 @@ dependencies {
9394
implementation("com.google.android.material:material:1.12.0")
9495
implementation("androidx.lifecycle:lifecycle-process:2.10.0")
9596
testImplementation("junit:junit:4.13.2")
96-
androidTestImplementation("androidx.test.ext:junit:1.1.4")
97-
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
97+
androidTestImplementation("androidx.test.ext:junit:1.3.0")
98+
androidTestImplementation("androidx.test:runner:1.7.0")
9899
}
99100

100101
apply(from = file("tauri.build.gradle.kts"))
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
3+
<!-- Installable only as an explicit test artifact, never a normal app release. -->
4+
<application android:testOnly="true" android:allowBackup="false" />
5+
</manifest>
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package dev.vmanyware.client
2+
3+
import android.content.Context
4+
import android.os.Build
5+
import android.util.Base64
6+
import androidx.test.ext.junit.runners.AndroidJUnit4
7+
import androidx.test.platform.app.InstrumentationRegistry
8+
import java.security.KeyStore
9+
import java.security.MessageDigest
10+
import java.util.UUID
11+
import org.junit.After
12+
import org.junit.Assert.*
13+
import org.junit.Before
14+
import org.junit.Test
15+
import org.junit.runner.RunWith
16+
17+
// Test APK only: no corruption/deletion hook is exposed in the application or IPC.
18+
@RunWith(AndroidJUnit4::class)
19+
class IdentityVaultInstrumentedTest {
20+
private val scopes = mutableListOf<String>()
21+
private val context get() = InstrumentationRegistry.getInstrumentation().targetContext
22+
private val preferences get() = context.getSharedPreferences("ssh-unlock-v1", Context.MODE_PRIVATE)
23+
private val vault get() = IdentityVault(context)
24+
private fun store() = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
25+
private fun alias(scope: String) = "dev.vmanyware.client.ssh-unlock.v1.$scope"
26+
private fun scope(): String = MessageDigest.getInstance("SHA-256")
27+
.digest(("owned-vault-instrumentation-" + UUID.randomUUID()).toByteArray())
28+
.joinToString("") { "%02x".format(it.toInt() and 255) }.also { scopes.add(it) }
29+
private fun secret() = ("Disposable instrumentation phrase " + UUID.randomUUID()).toByteArray()
30+
private fun refused(action: () -> Unit) {
31+
var rejected = false
32+
try { action() } catch (_: Exception) { rejected = true }
33+
assertTrue("Operation unexpectedly accepted", rejected)
34+
}
35+
36+
@Before
37+
fun requireOwnedEmulatorApproval() {
38+
check(InstrumentationRegistry.getArguments().getString("vmanywareOwnedVault") ==
39+
"vmanyware-native-smoke:emulator-5580:reset-approved") { "Explicit owned fixture approval required" }
40+
check(Build.HARDWARE == "ranchu" || Build.HARDWARE == "goldfish") { "Physical targets refused" }
41+
check(context.packageName == "dev.vmanyware.client") { "Wrong target package" }
42+
}
43+
44+
@After
45+
fun removeOnlyGeneratedScopes() {
46+
// No enumeration or deletion of ordinary application or user credentials.
47+
scopes.forEach { vault.forget(it) }
48+
}
49+
50+
@Test
51+
fun roundtripReopensAndForgetPreservesIsolation() {
52+
val scope = scope()
53+
val original = secret()
54+
val input = original.copyOf()
55+
vault.save(scope, input)
56+
assertTrue(input.all { it == 0.toByte() })
57+
val read = IdentityVault(context).read(scope)
58+
try { assertArrayEquals(original, read) } finally { read.fill(0) }
59+
val encoded = preferences.getString(scope, null)!!
60+
assertFalse(encoded.contains(original.toString(Charsets.UTF_8)))
61+
val unrelated = scope()
62+
refused { vault.read(unrelated) }
63+
vault.forget(scope)
64+
vault.forget(scope)
65+
assertFalse(preferences.contains(scope))
66+
assertFalse(store().containsAlias(alias(scope)))
67+
refused { vault.read(scope) }
68+
original.fill(0)
69+
}
70+
71+
@Test
72+
fun authenticatedCiphertextRefusesTamperingAndWrongScope() {
73+
val first = scope()
74+
val second = scope()
75+
vault.save(first, secret())
76+
vault.save(second, secret())
77+
val encoded = preferences.getString(first, null)!!
78+
val bytes = Base64.decode(encoded, Base64.NO_WRAP)
79+
bytes[bytes.lastIndex] = (bytes.last().toInt() xor 1).toByte()
80+
assertTrue(preferences.edit().putString(first, Base64.encodeToString(bytes, Base64.NO_WRAP)).commit())
81+
refused { vault.read(first) }
82+
assertTrue(preferences.edit().putString(first, encoded).putString(second, encoded).commit())
83+
refused { vault.read(second) }
84+
vault.read(first).fill(0)
85+
}
86+
87+
@Test
88+
fun lostWrappingKeyCannotSilentlyReplaceRetainedCiphertext() {
89+
val scope = scope()
90+
vault.save(scope, secret())
91+
val encoded = preferences.getString(scope, null)
92+
store().deleteEntry(alias(scope))
93+
assertFalse(store().containsAlias(alias(scope)))
94+
refused { vault.read(scope) }
95+
val input = secret()
96+
refused { vault.save(scope, input) }
97+
assertTrue(input.all { it == 0.toByte() })
98+
assertEquals(encoded, preferences.getString(scope, null))
99+
assertFalse(store().containsAlias(alias(scope)))
100+
vault.forget(scope)
101+
vault.save(scope, secret())
102+
vault.read(scope).fill(0)
103+
}
104+
105+
@Test
106+
fun invalidScopeVersionAndBoundsFailClosed() {
107+
val scope = scope()
108+
val large = ByteArray(1025) { 65 }
109+
refused { vault.save(scope, large) }
110+
assertTrue(large.all { it == 0.toByte() })
111+
val input = secret()
112+
refused { vault.save("../outside-owned-scope", input) }
113+
assertTrue(input.all { it == 0.toByte() })
114+
assertFalse(store().containsAlias(alias(scope)))
115+
vault.save(scope, secret())
116+
val bytes = Base64.decode(preferences.getString(scope, null), Base64.NO_WRAP)
117+
bytes[0] = 2
118+
assertTrue(preferences.edit().putString(scope, Base64.encodeToString(bytes, Base64.NO_WRAP)).commit())
119+
refused { vault.read(scope) }
120+
assertTrue(preferences.edit().putString(scope, "A".repeat(1501)).commit())
121+
refused { vault.read(scope) }
122+
}
123+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# 0089 — Separate Android Keystore acceptance artifacts
2+
3+
Status: test APK and read-only plan compile/verify; runtime execution is not authorized
4+
or demonstrated by this slice.
5+
6+
## Decision
7+
8+
Prepare a separate test-only instrumented APK to exercise actual Android Keystore
9+
negative cases, alongside the existing packaged production IPC driver. Do not add
10+
corruption, alias enumeration, key deletion or credential-file hooks to production
11+
commands. The test requires an explicit owned-emulator marker, QEMU hardware and
12+
the expected application package before creating scopes. Every scope is random,
13+
fixture-owned and cleaned up individually. No ordinary application alias is read
14+
or enumerated. Four instrumented tests cover authenticated roundtrip/forget,
15+
ciphertext/tag corruption and wrong scope, wrapping-key loss without silent
16+
regeneration, and malformed scope/version/size with save-input clearing.
17+
18+
The existing native driver gains a Keystore mode for the same generated app identity
19+
before/after force-stop/relaunch. It verifies save/forget confirmation, wrong-secret
20+
refusal, same public identity, successful vault unlock after process restart,
21+
forget/read refusal and unchanged manual unlock. The installed standalone APK must
22+
match the reviewed local SHA-256 before app reset. Three host-only fake-ADB tests
23+
prove early refusal; they do not emulate Android or Keystore.
24+
25+
A read-only planner uses SDK apksigner/aapt2 and streams APK digests. It verifies
26+
package/ABI/test runner/test-only status and matching main/test signers, then records
27+
exact artifacts, host-controller hash, fixed owned target and operations. Runtime
28+
execution requires explicit opt-in, recomputes the plan before any ADB call and
29+
refuses a different AVD, physical device, unbooted target or ABI. It installs only
30+
reviewed APKs, resets only the owned application, requires exact native/report and
31+
four-case success, stops the app and removes the test APK it installed. It never
32+
starts a VM. VM creation/start requires its own concrete approval (AGENTS.md).
33+
34+
Use AndroidX JUnit 1.3.0 and runner 1.7.0 only in androidTest. They replace the unused
35+
generated test dependencies; no application runtime dependency or permission is
36+
added. The test APK explicitly requires test installation and disables backup.
37+
Targeted test packaging reuses already verified unchanged production native
38+
libraries, excluding the Rust task whose invocation requires a live Tauri CLI
39+
options server. Kotlin/test APK compilation is real; cryptographic runtime proof
40+
is still absent. STATE.md records actual build/plan/host-check evidence and hashes.
41+
42+
## Limits
43+
44+
No ADB/device/emulator, credential mutation, real VM or host service is used while
45+
preparing the plan. Android runtime, system-source SSH/activity recreation, physical
46+
security level, biometric and OEM backup gates remain open. Test-scoped acceptance
47+
must not change full platform/release status. The named disposable AVD and both APK
48+
operations must be approved before running; do not infer approval from available
49+
SDK files or from successful compilation.
50+
51+
[AndroidX Test releases](https://developer.android.com/jetpack/androidx/releases/test)
52+
provide the pinned test-library versions. Existing app TLS/SSH verification remains
53+
unchanged.

0 commit comments

Comments
 (0)