Switch from i2p/eddsa to Bouncy Castle for Ed25519

The i2p eddsa library produces non-standard Ed25519 signatures that
Node.js crypto doesn't accept. Bouncy Castle is more widely tested
and should produce standard-compliant signatures.
This commit is contained in:
James (ClawdBot) 2026-01-28 19:25:57 +00:00
parent 5b140362bf
commit 1f58f36470
2 changed files with 32 additions and 59 deletions

View File

@ -54,8 +54,8 @@ dependencies {
// Security - encrypted storage // Security - encrypted storage
implementation("androidx.security:security-crypto:1.1.0-alpha06") implementation("androidx.security:security-crypto:1.1.0-alpha06")
// Ed25519 signatures (pure Java, works on all Android versions) // Ed25519 signatures via Bouncy Castle (reliable, widely tested)
implementation("net.i2p.crypto:eddsa:0.3.0") implementation("org.bouncycastle:bcprov-jdk18on:1.77")
// Networking - WebSocket // Networking - WebSocket
implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okhttp3:okhttp:4.12.0")

View File

@ -5,21 +5,17 @@ import android.util.Base64
import android.util.Log import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
import net.i2p.crypto.eddsa.EdDSAEngine import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
import net.i2p.crypto.eddsa.EdDSAPrivateKey import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
import net.i2p.crypto.eddsa.EdDSAPublicKey import org.bouncycastle.crypto.signers.Ed25519Signer
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.MessageDigest
import java.security.SecureRandom 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. * 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: * The key format matches the Clawdbot gateway protocol:
* - Public key: 32 bytes raw, base64url-encoded * - Public key: 32 bytes raw, base64url-encoded
@ -33,8 +29,6 @@ class DeviceIdentity(context: Context) {
private val keyPrivate = "private_key" private val keyPrivate = "private_key"
private val keyPublic = "public_key" private val keyPublic = "public_key"
private val ed25519Spec = EdDSANamedCurveTable.getByName("Ed25519")
private val prefs by lazy { private val prefs by lazy {
val masterKey = MasterKey.Builder(context) val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
@ -54,9 +48,8 @@ class DeviceIdentity(context: Context) {
*/ */
val publicKey: String by lazy { val publicKey: String by lazy {
ensureKeyPair() ensureKeyPair()
val publicKeyBytes = prefs.getString(keyPublic, null) prefs.getString(keyPublic, null)
?: throw IllegalStateException("No public key available") ?: 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) val publicKeyBase64 = prefs.getString(keyPublic, null)
?: throw IllegalStateException("No public key available") ?: throw IllegalStateException("No public key available")
// Decode the raw public key bytes
val publicKeyBytes = base64UrlDecode(publicKeyBase64) val publicKeyBytes = base64UrlDecode(publicKeyBase64)
// Create fingerprint using SHA-256 of the raw public key bytes
val digest = MessageDigest.getInstance("SHA-256") val digest = MessageDigest.getInstance("SHA-256")
val hash = digest.digest(publicKeyBytes) val hash = digest.digest(publicKeyBytes)
// Return as hex string (full 32 bytes = 64 chars, matching gateway)
hash.joinToString("") { "%02x".format(it) } hash.joinToString("") { "%02x".format(it) }
} }
/** /**
* Sign a challenge nonce for gateway authentication * Sign a challenge nonce for gateway authentication
*
* @param nonce The challenge nonce from the gateway
* @return Signature and timestamp
*/ */
fun signChallenge(nonce: String): SignedChallenge { fun signChallenge(nonce: String): SignedChallenge {
ensureKeyPair() ensureKeyPair()
@ -92,36 +78,32 @@ class DeviceIdentity(context: Context) {
Log.d(tag, "Signing payload: $payload") Log.d(tag, "Signing payload: $payload")
// Load private key // Load private key (32-byte seed)
val privateKeyBase64 = prefs.getString(keyPrivate, null) val privateKeyBase64 = prefs.getString(keyPrivate, null)
?: throw IllegalStateException("No private key available") ?: throw IllegalStateException("No private key available")
val privateKeyBytes = base64UrlDecode(privateKeyBase64) val privateKeyBytes = base64UrlDecode(privateKeyBase64)
// Create EdDSA private key from seed // Create Bouncy Castle Ed25519 private key from seed
val privateKeySpec = EdDSAPrivateKeySpec(privateKeyBytes, ed25519Spec) val privateKey = Ed25519PrivateKeyParameters(privateKeyBytes, 0)
val privateKey = EdDSAPrivateKey(privateKeySpec)
// Verify the derived public key matches stored public key // Verify derived public key matches stored
val derivedPubKey = privateKey.abyte val derivedPubKey = privateKey.generatePublicKey().encoded
val storedPubKeyBase64 = prefs.getString(keyPublic, null) val storedPubKeyBase64 = prefs.getString(keyPublic, null)
val storedPubKey = storedPubKeyBase64?.let { base64UrlDecode(it) } val storedPubKey = storedPubKeyBase64?.let { base64UrlDecode(it) }
val keysMatch = derivedPubKey.contentEquals(storedPubKey)
Log.d(tag, "Stored pubkey: ${storedPubKeyBase64?.take(20)}...") Log.d(tag, "Keys match: $keysMatch")
Log.d(tag, "Derived pubkey: ${base64UrlEncode(derivedPubKey).take(20)}...")
Log.d(tag, "Keys match: ${derivedPubKey.contentEquals(storedPubKey)}")
// Sign the payload using standard Ed25519 (not prehashed Ed25519ph) // Sign using Ed25519
val signature = EdDSAEngine().apply { val signer = Ed25519Signer()
initSign(privateKey) signer.init(true, privateKey)
update(payload.toByteArray(Charsets.UTF_8)) signer.update(payload.toByteArray(Charsets.UTF_8), 0, payload.length)
} val signatureBytes = signer.generateSignature()
val signatureBytes = signature.sign()
val signatureBase64 = base64UrlEncode(signatureBytes) val signatureBase64 = base64UrlEncode(signatureBytes)
Log.d(tag, "Generated signature: ${signatureBase64.take(20)}... (${signatureBytes.size} bytes)") 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)}..." val debugInfo = "Keys match: $keysMatch | Stored: ${storedPubKeyBase64?.take(12)}... | Derived: ${base64UrlEncode(derivedPubKey).take(12)}..."
return SignedChallenge( 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() { private fun generateKeyPair() {
Log.i(tag, "Generating new Ed25519 device keypair") Log.i(tag, "Generating new Ed25519 device keypair (Bouncy Castle)")
val keyPairGenerator = KeyPairGenerator() // Generate random 32-byte seed
keyPairGenerator.initialize(256, SecureRandom()) val seed = ByteArray(32)
val keyPair = keyPairGenerator.generateKeyPair() SecureRandom().nextBytes(seed)
val privateKey = keyPair.private as EdDSAPrivateKey // Create keypair from seed
val publicKey = keyPair.public as EdDSAPublicKey val privateKey = Ed25519PrivateKeyParameters(seed, 0)
val publicKey = privateKey.generatePublicKey()
// Get raw 32-byte keys (matching gateway format) val publicKeyBytes = publicKey.encoded
// 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") Log.d(tag, "Generated keypair: seed=${seed.size} bytes, public=${publicKeyBytes.size} bytes")
// Store as base64url // Store as base64url
prefs.edit() prefs.edit()
.putString(keyPrivate, base64UrlEncode(privateKeyBytes)) .putString(keyPrivate, base64UrlEncode(seed))
.putString(keyPublic, base64UrlEncode(publicKeyBytes)) .putString(keyPublic, base64UrlEncode(publicKeyBytes))
.apply() .apply()
@ -190,16 +169,10 @@ class DeviceIdentity(context: Context) {
Log.i(tag, "Device keypair deleted") Log.i(tag, "Device keypair deleted")
} }
/**
* Base64url encode (no padding, URL-safe)
*/
private fun base64UrlEncode(bytes: ByteArray): String { private fun base64UrlEncode(bytes: ByteArray): String {
return Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) 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 { private fun base64UrlDecode(input: String): ByteArray {
return Base64.decode(input, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) 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 signature: String,
val signedAt: Long, val signedAt: Long,
val nonce: String, val nonce: String,
val debugInfo: String = "" // For debugging key derivation val debugInfo: String = ""
) )
} }