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
This commit is contained in:
parent
0c3e45f0de
commit
95a360354c
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue