200 lines
6.7 KiB
Kotlin
200 lines
6.7 KiB
Kotlin
package com.inou.clawdnode.security
|
|
|
|
import android.content.Context
|
|
import android.util.Base64
|
|
import android.util.Log
|
|
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 Ed25519 keys.
|
|
*
|
|
* 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 prefsName = "clawdnode_device_identity"
|
|
private val keyPrivate = "private_key"
|
|
private val keyPublic = "public_key"
|
|
|
|
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 the device's public key as base64url-encoded raw bytes (32 bytes)
|
|
*/
|
|
val publicKey: String by lazy {
|
|
ensureKeyPair()
|
|
val publicKeyBytes = prefs.getString(keyPublic, null)
|
|
?: throw IllegalStateException("No public key available")
|
|
publicKeyBytes // Already stored as base64url
|
|
}
|
|
|
|
/**
|
|
* Get device ID (SHA-256 fingerprint of the public key raw bytes)
|
|
*/
|
|
val deviceId: String by lazy {
|
|
ensureKeyPair()
|
|
val publicKeyBase64 = prefs.getString(keyPublic, null)
|
|
?: throw IllegalStateException("No public key available")
|
|
|
|
// Decode the raw public key bytes
|
|
val publicKeyBytes = base64UrlDecode(publicKeyBase64)
|
|
|
|
// 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) }
|
|
}
|
|
|
|
/**
|
|
* 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 payload = "$nonce:$signedAt"
|
|
|
|
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)
|
|
|
|
// 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(payload.toByteArray(Charsets.UTF_8))
|
|
}
|
|
|
|
val signatureBytes = signature.sign()
|
|
val signatureBase64 = base64UrlEncode(signatureBytes)
|
|
|
|
Log.d(tag, "Generated signature: ${signatureBase64.take(20)}... (${signatureBytes.size} bytes)")
|
|
|
|
return SignedChallenge(
|
|
signature = signatureBase64,
|
|
signedAt = signedAt,
|
|
nonce = nonce
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Ensure the keypair exists, generating if necessary
|
|
*/
|
|
private fun ensureKeyPair() {
|
|
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 Ed25519 keypair and store it
|
|
*/
|
|
private fun generateKeyPair() {
|
|
Log.i(tag, "Generating new Ed25519 device keypair")
|
|
|
|
val keyPairGenerator = KeyPairGenerator()
|
|
keyPairGenerator.initialize(256, SecureRandom())
|
|
val keyPair = keyPairGenerator.generateKeyPair()
|
|
|
|
val privateKey = keyPair.private as EdDSAPrivateKey
|
|
val publicKey = keyPair.public as EdDSAPublicKey
|
|
|
|
// 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.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() {
|
|
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(
|
|
val signature: String,
|
|
val signedAt: Long,
|
|
val nonce: String
|
|
)
|
|
}
|