diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e4836c4..dcf66b6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -54,8 +54,8 @@ 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") + // Ed25519 signatures via Bouncy Castle (reliable, widely tested) + implementation("org.bouncycastle:bcprov-jdk18on:1.77") // 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 7f42fbb..d325234 100644 --- a/app/src/main/java/com/inou/clawdnode/security/DeviceIdentity.kt +++ b/app/src/main/java/com/inou/clawdnode/security/DeviceIdentity.kt @@ -5,21 +5,17 @@ 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 org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters +import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters +import org.bouncycastle.crypto.signers.Ed25519Signer import java.security.MessageDigest import java.security.SecureRandom /** - * Manages device identity using Ed25519 keys. + * Manages device identity using Ed25519 keys via Bouncy Castle. * * Keys are stored in EncryptedSharedPreferences for security. - * Uses pure-Java Ed25519 implementation for compatibility with all Android versions. + * Uses Bouncy Castle's Ed25519 implementation for compatibility. * * The key format matches the Clawdbot gateway protocol: * - Public key: 32 bytes raw, base64url-encoded @@ -33,8 +29,6 @@ class DeviceIdentity(context: Context) { 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) @@ -54,9 +48,8 @@ class DeviceIdentity(context: Context) { */ val publicKey: String by lazy { ensureKeyPair() - val publicKeyBytes = prefs.getString(keyPublic, null) + prefs.getString(keyPublic, null) ?: throw IllegalStateException("No public key available") - publicKeyBytes // Already stored as base64url } /** @@ -67,22 +60,15 @@ class DeviceIdentity(context: Context) { 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() @@ -92,36 +78,32 @@ class DeviceIdentity(context: Context) { Log.d(tag, "Signing payload: $payload") - // Load private key + // Load private key (32-byte seed) val privateKeyBase64 = prefs.getString(keyPrivate, null) ?: throw IllegalStateException("No private key available") val privateKeyBytes = base64UrlDecode(privateKeyBase64) - // Create EdDSA private key from seed - val privateKeySpec = EdDSAPrivateKeySpec(privateKeyBytes, ed25519Spec) - val privateKey = EdDSAPrivateKey(privateKeySpec) + // Create Bouncy Castle Ed25519 private key from seed + val privateKey = Ed25519PrivateKeyParameters(privateKeyBytes, 0) - // Verify the derived public key matches stored public key - val derivedPubKey = privateKey.abyte + // Verify derived public key matches stored + val derivedPubKey = privateKey.generatePublicKey().encoded val storedPubKeyBase64 = prefs.getString(keyPublic, null) val storedPubKey = storedPubKeyBase64?.let { base64UrlDecode(it) } + val keysMatch = derivedPubKey.contentEquals(storedPubKey) - Log.d(tag, "Stored pubkey: ${storedPubKeyBase64?.take(20)}...") - Log.d(tag, "Derived pubkey: ${base64UrlEncode(derivedPubKey).take(20)}...") - Log.d(tag, "Keys match: ${derivedPubKey.contentEquals(storedPubKey)}") + Log.d(tag, "Keys match: $keysMatch") - // Sign the payload using standard Ed25519 (not prehashed Ed25519ph) - val signature = EdDSAEngine().apply { - initSign(privateKey) - update(payload.toByteArray(Charsets.UTF_8)) - } + // Sign using Ed25519 + val signer = Ed25519Signer() + signer.init(true, privateKey) + signer.update(payload.toByteArray(Charsets.UTF_8), 0, payload.length) + val signatureBytes = signer.generateSignature() - val signatureBytes = signature.sign() val signatureBase64 = base64UrlEncode(signatureBytes) Log.d(tag, "Generated signature: ${signatureBase64.take(20)}... (${signatureBytes.size} bytes)") - val keysMatch = derivedPubKey.contentEquals(storedPubKey) val debugInfo = "Keys match: $keysMatch | Stored: ${storedPubKeyBase64?.take(12)}... | Derived: ${base64UrlEncode(derivedPubKey).take(12)}..." return SignedChallenge( @@ -150,29 +132,26 @@ class DeviceIdentity(context: Context) { } /** - * Generate a new Ed25519 keypair and store it + * Generate a new Ed25519 keypair using Bouncy Castle */ private fun generateKeyPair() { - Log.i(tag, "Generating new Ed25519 device keypair") + Log.i(tag, "Generating new Ed25519 device keypair (Bouncy Castle)") - val keyPairGenerator = KeyPairGenerator() - keyPairGenerator.initialize(256, SecureRandom()) - val keyPair = keyPairGenerator.generateKeyPair() + // Generate random 32-byte seed + val seed = ByteArray(32) + SecureRandom().nextBytes(seed) - val privateKey = keyPair.private as EdDSAPrivateKey - val publicKey = keyPair.public as EdDSAPublicKey + // Create keypair from seed + val privateKey = Ed25519PrivateKeyParameters(seed, 0) + val publicKey = privateKey.generatePublicKey() - // 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 + val publicKeyBytes = publicKey.encoded - Log.d(tag, "Generated keypair: private=${privateKeyBytes.size} bytes, public=${publicKeyBytes.size} bytes") + Log.d(tag, "Generated keypair: seed=${seed.size} bytes, public=${publicKeyBytes.size} bytes") // Store as base64url prefs.edit() - .putString(keyPrivate, base64UrlEncode(privateKeyBytes)) + .putString(keyPrivate, base64UrlEncode(seed)) .putString(keyPublic, base64UrlEncode(publicKeyBytes)) .apply() @@ -190,16 +169,10 @@ class DeviceIdentity(context: Context) { 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) } @@ -208,6 +181,6 @@ class DeviceIdentity(context: Context) { val signature: String, val signedAt: Long, val nonce: String, - val debugInfo: String = "" // For debugging key derivation + val debugInfo: String = "" ) }