Skip to content

Commit b4c2beb

Browse files
committed
Implement Android Keystore protection for SSH unlock
1 parent 5a36f99 commit b4c2beb

19 files changed

Lines changed: 459 additions & 35 deletions

‎README.md‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ Implemented, with local/generated-provider evidence:
4040
- Snapshot creation/restore, deletion and retained-definition recreation.
4141
- Durable operations, idempotent requests and read-only restart recovery.
4242
- SSH host trust, encrypted application identity and an interactive native terminal.
43-
- Optional Linux system key store for the application identity unlock passphrase.
43+
- Optional Linux Secret Service and Android Keystore protection for the identity
44+
unlock passphrase (Android runtime acceptance pending).
4445
- API/worker execution of declared packages and an optional final disk-only snapshot.
4546

4647
The package/final-snapshot workflow passes all create/recreate combinations against
@@ -71,9 +72,12 @@ target. Start with the [template preparation guide](docs/debian-template.md) and
7172

7273
The optional Linux Secret Service controls pass isolated real-daemon/native SSH,
7374
browser and joined Linux WebView/production IPC/terminal checks. The private key
74-
remains encrypted in application storage; macOS Keychain/Android Keystore and
75-
normal deployment/platform acceptance remain open. Current delivery files predate these new vault commands. See
76-
[the decision and limits](docs/decisions/0087-linux-system-identity-unlock.md).
75+
remains encrypted in application storage. Android Keystore now compiles/packages
76+
for both ABIs, with scoped ciphertext and JNI checks; actual device behavior, macOS
77+
Keychain and normal deployment/platform acceptance remain open. Current Android
78+
APKs include the vault commands but have not been executed. See
79+
[Linux decision](docs/decisions/0087-linux-system-identity-unlock.md) and
80+
[Android decision and limits](docs/decisions/0088-android-keystore-identity-unlock.md).
7781

7882
## Install
7983

‎STATE.md‎

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

3-
Updated: 2026-10-21. Historical evidence and decisions are in Git and docs.
3+
Updated: 2026-10-22. 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-
ADR 0087 adds optional Linux Secret Service storage of the application SSH unlock
12-
passphrase: explicit save/unlock/forget, scoped lookup and SSH source selection.
13-
The private SSH key stays encrypted in the existing app file. Eighteen affected
14-
native SSH checks, a focused namespace check, two isolated real-daemon/vault/SSH
15-
cases, React/browser journeys and joined Linux WebView/production IPC/vault/terminal
16-
acceptance pass. No personal vault is accessed.
17-
Clippy strict exposes one pre-existing unrelated argument-count lint; the client
18-
check passes with only that lint allowed. New GUI evidence is isolated Linux
19-
acceptance; device/normal-deployment gates stay open.
20-
Next: Android Keystore implementation using the existing native platform bridge,
21-
with source/compilation/device evidence distinguished. Current server/Android
22-
delivery artifacts include ADR 0086 progress
23-
but predate this new vault IPC/UI. Android runtime, real Proxmox, other OS stores,
24-
Windows/iOS parity, host deployment and local-provider acceptance remain open.
25-
The previous source/delivery checkpoint is retained in the dated entries below.
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.
2627

2728

2829
Latest source slice: production HTTP admission now has a global burst-120,
@@ -6839,3 +6840,65 @@ and initializes the platform verifier before Tauri starts. Add a narrowly scoped
68396840
keystore bridge alongside that path without altering TLS verification or
68406841
regenerating the reviewed Android project. Use the existing owned build toolchain;
68416842
source/compilation never substitutes for explicit device/AVD runtime acceptance.
6843+
6844+
6845+
## 2026-10-22 — Android Keystore identity unlock, session checkpoint (ADR 0088)
6846+
6847+
Scope/acceptance: Android implementation for the same explicit save/use/forget
6848+
identity commands, key/directory scope binding, fixed failures/no silent fallback,
6849+
source/native/Kotlin compilation and package checks. Implemented a narrow JNI
6850+
helper retained via application context and initialized alongside unchanged TLS
6851+
verification. Startup does not open a vault. Rust computes a purpose/directory/
6852+
fingerprint SHA-256 scope, verifies the existing encrypted key/passphrase before
6853+
save, confirms readback and retains shared busy/host/owner/session gates. Kotlin
6854+
accepts only scoped aliases, wraps with AndroidKeyStore AES-256-GCM, binds scope as
6855+
AAD and stores only bounded versioned ciphertext in private SharedPreferences.
6856+
Existing backup/device-transfer exclusions cover that data. Missing/invalidated
6857+
keys behind ciphertext cannot be silently regenerated. Explicit forget removes
6858+
only the scoped key/ciphertext, preserving the SSH key. Fixed native exceptions
6859+
never return Java/provider errors or plaintext to the renderer. UI labels now
6860+
support Linux/Android and preserve separate unchecked consent. No new dependency,
6861+
IPC command (82 total), network permission or automatic migration is added.
6862+
6863+
Verification: `make package-android` passes with the existing owned SDK/JDK/NDK
6864+
(`.lab/android-toolchain/keystore-package.log`): frontend/typecheck, ARM64/x86_64
6865+
native compilation, Kotlin/Gradle, both signed debug APKs and signature/version/
6866+
ABI/manifest/exact native-library checks. Initial compile errors (inner doc position
6867+
and typed JNI byte-array signatures) were corrected; final compile/package handles
6868+
exited successfully. An additional package check requires IdentityVault in DEX
6869+
(`keystore-content-check.log` in that directory). Compiled Kotlin JNI descriptors
6870+
match save/read/forget, and both new vault and unchanged TLS initializer symbols
6871+
are exported (`keystore-jni-descriptors.log`, `keystore-jni-exports.log`).
6872+
6873+
The focused Linux-host Android-scope test passes
6874+
(`.lab/native-deps/android-keystore-scope-test.log`): stable lowercase 64-hex scope,
6875+
changed directory and changed public identity produce independent scopes. It does
6876+
not emulate Android cryptography. The affected React/Firefox simulated-IPC journey
6877+
passes (`.lab/android-keystore-ui-check.log`), including explicit vault operations,
6878+
input/consent clearing, scoped SSH selection, no automatic connection and private
6879+
error suppression. Scoped rustfmt, Python syntax, IPC-manifest consistency and diff
6880+
review pass. No unrelated server/Linux native suite or Clippy reran for Android
6881+
conditional branches/UI copy changes.
6882+
6883+
Current local test artifacts (`.lab/packages/android/`):
6884+
- ARM64 SHA-256 `5a6554b8e32c54d39321fb99c1d2b274be73ca0c6a75397c067793d7277d2166`, 485011736 bytes.
6885+
- x86_64 SHA-256 `0aa58facc6a8d659e76f64cdcc738c44ac2072ddac55c3429c9643d3a072ab47`, 492984213 bytes.
6886+
- `releaseCandidate=false`, `runtimeValidatedForTheseApks=false`; updated INSTALL
6887+
instructions match repository documentation. No device/AVD was started/installed,
6888+
personal credential/key store touched, real VM contacted or host service changed.
6889+
6890+
Limits: no actual Keystore save/decrypt, process/activity restart, ciphertext tamper,
6891+
wrapping-key invalidation/loss, OEM transfer behavior, physical security-level or
6892+
Android system-source SSH/PTY runtime evidence yet. No biometric/StrongBox claim:
6893+
the wrapping key has no user-authentication requirement, and a compromised app can
6894+
use it. The SSH key remains encrypted application storage. Forgetting saved unlock
6895+
does not revoke an already selected key/session. Source compilation cannot close
6896+
platform/phase acceptance. Existing Linux acceptance remains independently recorded.
6897+
6898+
Next task after resuming: use an approved owned Android target and current exact APK
6899+
for the acceptance sequence above; keep package provenance and no silent fallback.
6900+
If no runtime target is authorized, prepare its opt-in fixture without launching it,
6901+
then continue other source/platform work. User asked to stop at this checkpoint;
6902+
do not start another slice in this session. Website remains at the accepted Linux
6903+
checkpoint `11dcb6c`; Android source/package evidence is now in this repo and should
6904+
be synchronized on the next publication pass, without claiming device validation.

‎apps/client/src-tauri/gen/android/app/proguard-rules.pro‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,6 @@
2525
-keepclassmembers class dev.vmanyware.client.MainActivity {
2626
native <methods>;
2727
}
28+
29+
# Fixed native identity bridge methods must retain their JNI names.
30+
-keep class dev.vmanyware.client.IdentityVault { *; }
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package dev.vmanyware.client
2+
3+
import android.content.Context
4+
import android.security.keystore.KeyGenParameterSpec
5+
import android.security.keystore.KeyProperties
6+
import android.util.Base64
7+
import java.security.KeyStore
8+
import javax.crypto.Cipher
9+
import javax.crypto.KeyGenerator
10+
import javax.crypto.SecretKey
11+
import javax.crypto.spec.GCMParameterSpec
12+
13+
// Reached only by the fixed native identity commands, never by WebView Javascript.
14+
// Retain application context; constructing this helper does not open the vault.
15+
class IdentityVault(context: Context) {
16+
private val context = context.applicationContext
17+
private fun preferences() = context.getSharedPreferences("ssh-unlock-v1", Context.MODE_PRIVATE)
18+
private fun alias(scope: String): String {
19+
require(scope.matches(Regex("[0-9a-f]{64}")))
20+
return "dev.vmanyware.client.ssh-unlock.v1.$scope"
21+
}
22+
private fun store() = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
23+
private fun key(store: KeyStore, name: String): SecretKey =
24+
store.getKey(name, null) as? SecretKey ?: error("Unlock key unavailable")
25+
26+
@Synchronized
27+
fun save(scope: String, secret: ByteArray) {
28+
try {
29+
require(secret.isNotEmpty() && secret.size <= 1024)
30+
val name = alias(scope)
31+
val preferences = preferences()
32+
val store = store()
33+
// Never silently replace an invalidated/missing key behind retained ciphertext.
34+
check(!preferences.contains(scope) || store.containsAlias(name))
35+
if (!store.containsAlias(name)) {
36+
KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").apply {
37+
init(KeyGenParameterSpec.Builder(name, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
38+
.setKeySize(256).setBlockModes(KeyProperties.BLOCK_MODE_GCM)
39+
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
40+
.setRandomizedEncryptionRequired(true).setUserAuthenticationRequired(false).build())
41+
generateKey()
42+
}
43+
}
44+
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
45+
cipher.init(Cipher.ENCRYPT_MODE, key(store, name))
46+
cipher.updateAAD(scope.toByteArray(Charsets.US_ASCII))
47+
check(cipher.iv.size == 12)
48+
val encoded = Base64.encodeToString(byteArrayOf(1) + cipher.iv + cipher.doFinal(secret), Base64.NO_WRAP)
49+
check(preferences.edit().putString(scope, encoded).commit())
50+
} finally {
51+
secret.fill(0)
52+
}
53+
}
54+
55+
@Synchronized
56+
fun read(scope: String): ByteArray {
57+
val name = alias(scope)
58+
val encoded = preferences().getString(scope, null) ?: error("Saved unlock secret absent")
59+
require(encoded.length <= 1500)
60+
val bytes = Base64.decode(encoded, Base64.NO_WRAP)
61+
require(bytes.size in 30..1053 && bytes[0] == 1.toByte())
62+
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
63+
cipher.init(Cipher.DECRYPT_MODE, key(store(), name), GCMParameterSpec(128, bytes.copyOfRange(1, 13)))
64+
cipher.updateAAD(scope.toByteArray(Charsets.US_ASCII))
65+
val secret = cipher.doFinal(bytes, 13, bytes.size - 13)
66+
if (secret.isEmpty() || secret.size > 1024) { secret.fill(0); error("Invalid unlock secret") }
67+
return secret
68+
}
69+
70+
@Synchronized
71+
fun forget(scope: String) {
72+
val name = alias(scope)
73+
val store = store()
74+
if (store.containsAlias(name)) store.deleteEntry(name)
75+
check(!store.containsAlias(name))
76+
check(preferences().edit().remove(scope).commit())
77+
}
78+
}

‎apps/client/src-tauri/gen/android/app/src/main/java/dev/vmanyware/client/MainActivity.kt‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@ import androidx.core.view.WindowInsetsCompat
1313

1414
class MainActivity : TauriActivity() {
1515
private external fun initializePlatformVerifier(context: Context)
16+
private external fun initializeIdentityVault(helper: IdentityVault)
1617

1718
override fun onCreate(savedInstanceState: Bundle?) {
1819
System.loadLibrary("vmanyware_client_lib")
1920
initializePlatformVerifier(applicationContext)
21+
initializeIdentityVault(IdentityVault(applicationContext))
2022
enableEdgeToEdge(
2123
statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT),
2224
navigationBarStyle = SystemBarStyle.dark(Color.TRANSPARENT)
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
//! Narrow application-context JNI bridge. Initialization does not access secrets.
2+
use jni::{
3+
errors::ThrowRuntimeExAndDefault,
4+
jni_mangle, jni_sig, jni_str,
5+
objects::{Global, JByteArray, JObject, JValue},
6+
EnvUnowned, JavaVM,
7+
};
8+
use std::sync::OnceLock;
9+
use zeroize::Zeroizing;
10+
struct Bridge {
11+
vm: JavaVM,
12+
helper: Global<JObject<'static>>,
13+
}
14+
static BRIDGE: OnceLock<Bridge> = OnceLock::new();
15+
const ERROR: &str = "Android system key store unavailable or unable to open this identity. Use the passphrase or explicitly forget the saved secret before saving again.";
16+
#[allow(non_snake_case)]
17+
#[jni_mangle("dev.vmanyware.client.MainActivity")]
18+
pub fn initializeIdentityVault<'caller>(
19+
mut env: EnvUnowned<'caller>,
20+
_activity: JObject<'caller>,
21+
helper: JObject<'caller>,
22+
) {
23+
env.with_env(|env| -> jni::errors::Result<()> {
24+
if BRIDGE.get().is_none() {
25+
let bridge = Bridge {
26+
vm: env.get_java_vm()?,
27+
helper: env.new_global_ref(helper)?,
28+
};
29+
let _ = BRIDGE.set(bridge);
30+
}
31+
Ok(())
32+
})
33+
.resolve::<ThrowRuntimeExAndDefault>();
34+
}
35+
pub(crate) fn save(scope: &str, secret: &[u8]) -> Result<(), String> {
36+
let bridge = BRIDGE.get().ok_or(ERROR)?;
37+
bridge
38+
.vm
39+
.attach_current_thread_for_scope(|env| -> jni::errors::Result<()> {
40+
let scope = env.new_string(scope)?;
41+
let bytes = env.byte_array_from_slice(secret)?;
42+
let result = env.call_method(
43+
&bridge.helper,
44+
jni_str!("save"),
45+
jni_sig!("(Ljava/lang/String;[B)V"),
46+
&[
47+
JValue::Object(scope.as_ref()),
48+
JValue::Object(bytes.as_ref()),
49+
],
50+
);
51+
// Java clears this argument in finally, including encryption/storage failure.
52+
result?.v()
53+
})
54+
.map_err(|_| ERROR.into())
55+
}
56+
pub(crate) fn read(scope: &str) -> Result<Zeroizing<Vec<u8>>, String> {
57+
let bridge = BRIDGE.get().ok_or(ERROR)?;
58+
bridge
59+
.vm
60+
.attach_current_thread_for_scope(|env| -> jni::errors::Result<_> {
61+
let scope = env.new_string(scope)?;
62+
let object = env
63+
.call_method(
64+
&bridge.helper,
65+
jni_str!("read"),
66+
jni_sig!("(Ljava/lang/String;)[B"),
67+
&[JValue::Object(scope.as_ref())],
68+
)?
69+
.l()?;
70+
let bytes = env.cast_local::<JByteArray>(object)?;
71+
let len = bytes.len(env)?;
72+
if len > 1024 {
73+
return Err(jni::errors::Error::NullPtr("Invalid unlock secret"));
74+
}
75+
let result = Zeroizing::new(env.convert_byte_array(&bytes)?);
76+
bytes.set_region(env, 0, &[0i8; 1024][..len])?;
77+
Ok(result)
78+
})
79+
.map_err(|_| ERROR.into())
80+
}
81+
pub(crate) fn forget(scope: &str) -> Result<(), String> {
82+
let bridge = BRIDGE.get().ok_or(ERROR)?;
83+
bridge
84+
.vm
85+
.attach_current_thread_for_scope(|env| -> jni::errors::Result<()> {
86+
let scope = env.new_string(scope)?;
87+
env.call_method(
88+
&bridge.helper,
89+
jni_str!("forget"),
90+
jni_sig!("(Ljava/lang/String;)V"),
91+
&[JValue::Object(scope.as_ref())],
92+
)?
93+
.v()
94+
})
95+
.map_err(|_| ERROR.into())
96+
}

‎apps/client/src-tauri/src/lib.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
#[cfg(target_os = "android")]
2+
mod android_identity;
3+
#[cfg(target_os = "android")]
24
mod android_tls;
35
mod control_plane;
46
mod remote_connection;

‎apps/client/src-tauri/src/ssh/agent.rs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ impl AgentManager {
217217
self.list_application_inner(owner, host, port, username, Some(password))
218218
.await
219219
}
220-
#[cfg(target_os = "linux")]
220+
#[cfg(any(target_os = "linux", target_os = "android"))]
221221
pub async fn list_system_application(
222222
&self,
223223
owner: &str,
@@ -261,9 +261,9 @@ impl AgentManager {
261261
ensure_active(&unlocking)?;
262262
match password {
263263
Some(password) => store.signing_key(password),
264-
#[cfg(target_os = "linux")]
264+
#[cfg(any(target_os = "linux", target_os = "android"))]
265265
None => store.signing_system(),
266-
#[cfg(not(target_os = "linux"))]
266+
#[cfg(not(any(target_os = "linux", target_os = "android")))]
267267
None => {
268268
Err("System SSH identity storage is unavailable on this platform".into())
269269
}

‎apps/client/src-tauri/src/ssh/identity.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
//! Fixed app-owned encrypted Ed25519 identity. Never imports user key files.
2+
#[cfg(any(target_os = "android", all(test, target_os = "linux")))]
3+
#[path = "identity_android.rs"]
4+
mod android;
25
#[cfg(target_os = "linux")]
36
#[path = "identity_vault.rs"]
47
pub(super) mod vault;

0 commit comments

Comments
 (0)