From 95a360354c470ef4aa6c55e27478992c485d3d7b Mon Sep 17 00:00:00 2001 From: "James (ClawdBot)" Date: Wed, 28 Jan 2026 18:42:31 +0000 Subject: [PATCH] Fix device identity: switch from ECDSA to Ed25519 - Add net.i2p.crypto:eddsa library for pure-Java Ed25519 - Rewrite DeviceIdentity to use Ed25519 (matches gateway protocol) - Keys stored in EncryptedSharedPreferences instead of Android Keystore - Public key format: 32 bytes raw, base64url-encoded - Device ID: SHA-256 hash of raw public key - Improved error handling in GatewayClient challenge flow --- app/build.gradle.kts | 3 + .../inou/clawdnode/security/DeviceIdentity.kt | 165 ++++++++++++------ .../inou/clawdnode/service/GatewayClient.kt | 30 ++-- 3 files changed, 138 insertions(+), 60 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b4810d3..e4836c4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -54,6 +54,9 @@ dependencies { // Security - encrypted storage implementation("androidx.security:security-crypto:1.1.0-alpha06") + // Ed25519 signatures (pure Java, works on all Android versions) + implementation("net.i2p.crypto:eddsa:0.3.0") + // Networking - WebSocket implementation("com.squareup.okhttp3:okhttp:4.12.0") diff --git a/app/src/main/java/com/inou/clawdnode/security/DeviceIdentity.kt b/app/src/main/java/com/inou/clawdnode/security/DeviceIdentity.kt index c0a3e90..848454d 100644 --- a/app/src/main/java/com/inou/clawdnode/security/DeviceIdentity.kt +++ b/app/src/main/java/com/inou/clawdnode/security/DeviceIdentity.kt @@ -1,52 +1,81 @@ package com.inou.clawdnode.security import android.content.Context -import android.security.keystore.KeyGenParameterSpec -import android.security.keystore.KeyProperties import android.util.Base64 import android.util.Log -import java.security.* -import java.security.spec.ECGenParameterSpec +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import net.i2p.crypto.eddsa.EdDSAEngine +import net.i2p.crypto.eddsa.EdDSAPrivateKey +import net.i2p.crypto.eddsa.EdDSAPublicKey +import net.i2p.crypto.eddsa.KeyPairGenerator +import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable +import net.i2p.crypto.eddsa.spec.EdDSAPrivateKeySpec +import net.i2p.crypto.eddsa.spec.EdDSAPublicKeySpec +import java.security.MessageDigest +import java.security.SecureRandom /** - * Manages device identity using Android Keystore. + * Manages device identity using Ed25519 keys. * - * Generates and stores an EC P-256 keypair for signing Gateway challenge nonces. - * The private key never leaves the secure hardware (TEE/SE) on supported devices. + * Keys are stored in EncryptedSharedPreferences for security. + * Uses pure-Java Ed25519 implementation for compatibility with all Android versions. + * + * The key format matches the Clawdbot gateway protocol: + * - Public key: 32 bytes raw, base64url-encoded + * - Signature: 64 bytes raw, base64url-encoded + * - Payload: "$nonce:$signedAt" (matching gateway's expected format) */ class DeviceIdentity(context: Context) { private val tag = "DeviceIdentity" - private val keyAlias = "clawdnode_device_key" + private val prefsName = "clawdnode_device_identity" + private val keyPrivate = "private_key" + private val keyPublic = "public_key" - private val keyStore: KeyStore by lazy { - KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + private val ed25519Spec = EdDSANamedCurveTable.getByName("Ed25519") + + private val prefs by lazy { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + EncryptedSharedPreferences.create( + context, + prefsName, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) } /** - * Get or generate the device's public key + * Get the device's public key as base64url-encoded raw bytes (32 bytes) */ val publicKey: String by lazy { ensureKeyPair() - val publicKey = keyStore.getCertificate(keyAlias)?.publicKey + val publicKeyBytes = prefs.getString(keyPublic, null) ?: throw IllegalStateException("No public key available") - Base64.encodeToString(publicKey.encoded, Base64.NO_WRAP) + publicKeyBytes // Already stored as base64url } /** - * Get device ID (fingerprint of the public key) + * Get device ID (SHA-256 fingerprint of the public key raw bytes) */ val deviceId: String by lazy { ensureKeyPair() - val publicKey = keyStore.getCertificate(keyAlias)?.publicKey + val publicKeyBase64 = prefs.getString(keyPublic, null) ?: throw IllegalStateException("No public key available") - // Create fingerprint using SHA-256 of the public key - val digest = MessageDigest.getInstance("SHA-256") - val hash = digest.digest(publicKey.encoded) + // Decode the raw public key bytes + val publicKeyBytes = base64UrlDecode(publicKeyBase64) - // Return as hex string (first 16 bytes = 32 chars) - hash.take(16).joinToString("") { "%02x".format(it) } + // Create fingerprint using SHA-256 of the raw public key bytes + val digest = MessageDigest.getInstance("SHA-256") + val hash = digest.digest(publicKeyBytes) + + // Return as hex string (full 32 bytes = 64 chars, matching gateway) + hash.joinToString("") { "%02x".format(it) } } /** @@ -59,18 +88,29 @@ class DeviceIdentity(context: Context) { ensureKeyPair() val signedAt = System.currentTimeMillis() - val dataToSign = "$nonce:$signedAt" + val payload = "$nonce:$signedAt" - val privateKey = keyStore.getKey(keyAlias, null) as? PrivateKey + Log.d(tag, "Signing payload: $payload") + + // Load private key + val privateKeyBase64 = prefs.getString(keyPrivate, null) ?: throw IllegalStateException("No private key available") + val privateKeyBytes = base64UrlDecode(privateKeyBase64) - val signature = Signature.getInstance("SHA256withECDSA").apply { + // Create EdDSA private key + val privateKeySpec = EdDSAPrivateKeySpec(privateKeyBytes, ed25519Spec) + val privateKey = EdDSAPrivateKey(privateKeySpec) + + // Sign the payload + val signature = EdDSAEngine(MessageDigest.getInstance(ed25519Spec.hashAlgorithm)).apply { initSign(privateKey) - update(dataToSign.toByteArray(Charsets.UTF_8)) + update(payload.toByteArray(Charsets.UTF_8)) } val signatureBytes = signature.sign() - val signatureBase64 = Base64.encodeToString(signatureBytes, Base64.NO_WRAP) + val signatureBase64 = base64UrlEncode(signatureBytes) + + Log.d(tag, "Generated signature: ${signatureBase64.take(20)}... (${signatureBytes.size} bytes)") return SignedChallenge( signature = signatureBase64, @@ -83,47 +123,72 @@ class DeviceIdentity(context: Context) { * Ensure the keypair exists, generating if necessary */ private fun ensureKeyPair() { - if (!keyStore.containsAlias(keyAlias)) { - generateKeyPair() + try { + if (prefs.getString(keyPrivate, null) == null) { + Log.i(tag, "No existing keypair found, generating new Ed25519 keypair") + generateKeyPair() + } else { + Log.d(tag, "Using existing Ed25519 keypair") + } + } catch (e: Exception) { + Log.e(tag, "Failed to check/ensure keypair", e) + throw e } } /** - * Generate a new EC P-256 keypair in the Android Keystore + * Generate a new Ed25519 keypair and store it */ private fun generateKeyPair() { - Log.i(tag, "Generating new device keypair") + Log.i(tag, "Generating new Ed25519 device keypair") - val keyPairGenerator = KeyPairGenerator.getInstance( - KeyProperties.KEY_ALGORITHM_EC, - "AndroidKeyStore" - ) + val keyPairGenerator = KeyPairGenerator() + keyPairGenerator.initialize(256, SecureRandom()) + val keyPair = keyPairGenerator.generateKeyPair() - val parameterSpec = KeyGenParameterSpec.Builder( - keyAlias, - KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY - ).apply { - setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")) - setDigests(KeyProperties.DIGEST_SHA256) - // Require user auth for extra security (optional, can be removed if too restrictive) - // setUserAuthenticationRequired(true) - // setUserAuthenticationValidityDurationSeconds(300) - }.build() + val privateKey = keyPair.private as EdDSAPrivateKey + val publicKey = keyPair.public as EdDSAPublicKey - keyPairGenerator.initialize(parameterSpec) - keyPairGenerator.generateKeyPair() + // Get raw 32-byte keys (matching gateway format) + // For Ed25519, the "seed" is the 32-byte private key + val privateKeyBytes = privateKey.seed + // The "A" point is the 32-byte public key + val publicKeyBytes = publicKey.abyte - Log.i(tag, "Device keypair generated successfully") + Log.d(tag, "Generated keypair: private=${privateKeyBytes.size} bytes, public=${publicKeyBytes.size} bytes") + + // Store as base64url + prefs.edit() + .putString(keyPrivate, base64UrlEncode(privateKeyBytes)) + .putString(keyPublic, base64UrlEncode(publicKeyBytes)) + .apply() + + Log.i(tag, "Ed25519 device keypair generated and stored successfully") } /** * Delete the keypair (for testing/reset) */ fun deleteKeyPair() { - if (keyStore.containsAlias(keyAlias)) { - keyStore.deleteEntry(keyAlias) - Log.i(tag, "Device keypair deleted") - } + prefs.edit() + .remove(keyPrivate) + .remove(keyPublic) + .apply() + Log.i(tag, "Device keypair deleted") + } + + /** + * Base64url encode (no padding, URL-safe) + */ + private fun base64UrlEncode(bytes: ByteArray): String { + return Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) + } + + /** + * Base64url decode (handles both padded and unpadded) + */ + private fun base64UrlDecode(input: String): ByteArray { + return Base64.decode(input, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) } data class SignedChallenge( diff --git a/app/src/main/java/com/inou/clawdnode/service/GatewayClient.kt b/app/src/main/java/com/inou/clawdnode/service/GatewayClient.kt index 49a70e3..d5b8c76 100644 --- a/app/src/main/java/com/inou/clawdnode/service/GatewayClient.kt +++ b/app/src/main/java/com/inou/clawdnode/service/GatewayClient.kt @@ -218,12 +218,22 @@ class GatewayClient( return } - // Sign the challenge nonce - val signedChallenge = try { - deviceIdentity.signChallenge(nonce) + // Sign the challenge nonce and get device identity + // Wrap in try/catch - keystore operations can fail on first run or if hardware unavailable + val signedChallenge: DeviceIdentity.SignedChallenge? + val deviceId: String + val publicKey: String? + + try { + deviceId = deviceIdentity.deviceId + signedChallenge = deviceIdentity.signChallenge(nonce) + publicKey = deviceIdentity.publicKey + log("Device identity ready: id=${deviceId.take(8)}..., signed challenge") } catch (e: Exception) { - logError("Failed to sign challenge", e) - null + logError("Failed to initialize device identity or sign challenge", e) + // Cannot proceed without device identity for non-local connections + webSocket?.close(1000, "Device identity initialization failed: ${e.message}") + return } val connectRequest = ConnectRequest( @@ -248,11 +258,11 @@ class GatewayClient( auth = AuthInfo(token = token), userAgent = "clawdnode-android/${getAppVersion()}", device = DeviceInfo( - id = deviceIdentity.deviceId, - publicKey = signedChallenge?.let { deviceIdentity.publicKey }, - signature = signedChallenge?.signature, - signedAt = signedChallenge?.signedAt, - nonce = signedChallenge?.nonce + id = deviceId, + publicKey = publicKey, + signature = signedChallenge.signature, + signedAt = signedChallenge.signedAt, + nonce = signedChallenge.nonce ) ) )