135 lines
4.2 KiB
Kotlin
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
|
|
)
|
|
}
|