clawdnode-android/app/src/main/java/com/inou/clawdnode/security/DeviceIdentity.kt

135 lines
4.2 KiB
Kotlin

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
/**
* Manages device identity using Android Keystore.
*
* 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.
*/
class DeviceIdentity(context: Context) {
private val tag = "DeviceIdentity"
private val keyAlias = "clawdnode_device_key"
private val keyStore: KeyStore by lazy {
KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
}
/**
* Get or generate the device's public key
*/
val publicKey: String by lazy {
ensureKeyPair()
val publicKey = keyStore.getCertificate(keyAlias)?.publicKey
?: throw IllegalStateException("No public key available")
Base64.encodeToString(publicKey.encoded, Base64.NO_WRAP)
}
/**
* Get device ID (fingerprint of the public key)
*/
val deviceId: String by lazy {
ensureKeyPair()
val publicKey = keyStore.getCertificate(keyAlias)?.publicKey
?: 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)
// Return as hex string (first 16 bytes = 32 chars)
hash.take(16).joinToString("") { "%02x".format(it) }
}
/**
* Sign a challenge nonce for gateway authentication
*
* @param nonce The challenge nonce from the gateway
* @return Signature and timestamp
*/
fun signChallenge(nonce: String): SignedChallenge {
ensureKeyPair()
val signedAt = System.currentTimeMillis()
val dataToSign = "$nonce:$signedAt"
val privateKey = keyStore.getKey(keyAlias, null) as? PrivateKey
?: throw IllegalStateException("No private key available")
val signature = Signature.getInstance("SHA256withECDSA").apply {
initSign(privateKey)
update(dataToSign.toByteArray(Charsets.UTF_8))
}
val signatureBytes = signature.sign()
val signatureBase64 = Base64.encodeToString(signatureBytes, Base64.NO_WRAP)
return SignedChallenge(
signature = signatureBase64,
signedAt = signedAt,
nonce = nonce
)
}
/**
* Ensure the keypair exists, generating if necessary
*/
private fun ensureKeyPair() {
if (!keyStore.containsAlias(keyAlias)) {
generateKeyPair()
}
}
/**
* Generate a new EC P-256 keypair in the Android Keystore
*/
private fun generateKeyPair() {
Log.i(tag, "Generating new device keypair")
val keyPairGenerator = KeyPairGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_EC,
"AndroidKeyStore"
)
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()
keyPairGenerator.initialize(parameterSpec)
keyPairGenerator.generateKeyPair()
Log.i(tag, "Device keypair generated successfully")
}
/**
* Delete the keypair (for testing/reset)
*/
fun deleteKeyPair() {
if (keyStore.containsAlias(keyAlias)) {
keyStore.deleteEntry(keyAlias)
Log.i(tag, "Device keypair deleted")
}
}
data class SignedChallenge(
val signature: String,
val signedAt: Long,
val nonce: String
)
}