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:
parent
5b140362bf
commit
1f58f36470
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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 = ""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue