aboutsummaryrefslogtreecommitdiffstats
path: root/app/src
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-11-02 19:28:09 +0100
committerDanilo M. <danix@danix.xyz>2026-11-02 19:28:09 +0100
commitbf07c157071bea82000850ee431917fd5983d96d (patch)
tree42f674305e772554e23bcd04055369d7786c637c /app/src
parenta638af6fd58c767ed448134e6527c5c5f8429889 (diff)
downloadHardenedPass-bf07c157071bea82000850ee431917fd5983d96d.tar.gz
HardenedPass-bf07c157071bea82000850ee431917fd5983d96d.zip
build(deps): update sshj to 0.41.2 and drop eddsa
sshj 0.40 replaced the unmaintained net.i2p.crypto:eddsa 0.3.0 (last release 2019) with Bouncy Castle's Ed25519. 0.41.2 still targets Java 8. The app used eddsa directly for wrapped Ed25519 SSH keys. Generate the 32-byte seed with SecureRandom and build the key pair with sshj's Ed25519KeyFactory and Bouncy Castle's public key derivation. The wrapped format is still the raw RFC 8032 seed, so keys generated by 0.1.3 keep working; Ed25519KeyPairTest pins this with the RFC 8032 test vector. sshj 0.40 also changed setSecurityProvider(null) to reset the registerBouncyCastle flag, which undid the earlier setRegisterBouncyCastle(false): sshj then forced every operation through Bouncy Castle, which cannot sign with Android Keystore keys ("no encoding for EC private key"), and hardware-backed SSH keys failed to authenticate. Call the two in the opposite order; SshjConfigTest checks that sshj leaves provider selection to the JCA. Verified with the R8 release build on the emulator against the e2e server: a hardware ECDSA key, a newly generated wrapped Ed25519 key and an imported OpenSSH Ed25519 key all authenticated and synced. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'app/src')
-rw-r--r--app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt27
-rw-r--r--app/src/main/java/app/passwordstore/util/git/sshj/SshjConfig.kt6
-rw-r--r--app/src/test/java/app/passwordstore/util/git/sshj/Ed25519KeyPairTest.kt53
-rw-r--r--app/src/test/java/app/passwordstore/util/git/sshj/SshjConfigTest.kt22
4 files changed, 95 insertions, 13 deletions
diff --git a/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt b/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt
index 63975cc5..109e36c9 100644
--- a/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt
+++ b/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt
@@ -30,23 +30,24 @@ import com.github.michaelbull.result.runCatching
import java.io.File
import java.io.IOException
import java.security.KeyFactory
+import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.PrivateKey
import java.security.PublicKey
+import java.security.SecureRandom
import javax.crypto.SecretKey
import javax.crypto.SecretKeyFactory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import logcat.asLog
import logcat.logcat
-import net.i2p.crypto.eddsa.EdDSAPrivateKey
-import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable
-import net.i2p.crypto.eddsa.spec.EdDSAPrivateKeySpec
import net.schmizz.sshj.SSHClient
import net.schmizz.sshj.common.Buffer
+import net.schmizz.sshj.common.Ed25519KeyFactory
import net.schmizz.sshj.common.KeyType
import net.schmizz.sshj.userauth.keyprovider.KeyProvider
+import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
private const val PROVIDER_ANDROID_KEY_STORE = "AndroidKeyStore"
private const val KEYSTORE_ALIAS = "sshkey"
@@ -74,6 +75,13 @@ fun toSshPublicKey(publicKey: PublicKey): String {
return "$keyType ${Base64.encodeToString(rawPublicKey, Base64.NO_WRAP)}"
}
+/** The Ed25519 key pair for a 32-byte RFC 8032 private key [seed]. */
+internal fun ed25519KeyPair(seed: ByteArray) =
+ KeyPair(
+ Ed25519KeyFactory.getPublicKey(Ed25519PrivateKeyParameters(seed).generatePublicKey().encoded),
+ Ed25519KeyFactory.getPrivateKey(seed),
+ )
+
object SshKey {
val sshPublicKey
@@ -246,10 +254,9 @@ object SshKey {
if (requireAuthentication) requireUserAuthentication(15)
}
// Generate the ed25519 key pair and encrypt the private key.
- val keyPair = net.i2p.crypto.eddsa.KeyPairGenerator().generateKeyPair()
- privateKeyFile.writeBytes(
- seal(wrappingKey, (keyPair.private as EdDSAPrivateKey).seed, WRAPPED_KEY_AAD)
- )
+ val seed = ByteArray(32).also { SecureRandom().nextBytes(it) }
+ val keyPair = ed25519KeyPair(seed)
+ privateKeyFile.writeBytes(seal(wrappingKey, seed, WRAPPED_KEY_AAD))
// Write public key in SSH format to .ssh_key.pub.
publicKeyFile.writeText(toSshPublicKey(keyPair.public))
@@ -334,11 +341,9 @@ object SshKey {
override fun getPrivate(): PrivateKey =
runCatching {
- val rawPrivateKey =
+ val seed =
unseal(keystoreAesKey(KEYSTORE_ALIAS), privateKeyFile.readBytes(), WRAPPED_KEY_AAD)
- EdDSAPrivateKey(
- EdDSAPrivateKeySpec(rawPrivateKey, EdDSANamedCurveTable.ED_25519_CURVE_SPEC)
- )
+ Ed25519KeyFactory.getPrivateKey(seed)
}
.getOrElse { error ->
logcat { error.asLog() }
diff --git a/app/src/main/java/app/passwordstore/util/git/sshj/SshjConfig.kt b/app/src/main/java/app/passwordstore/util/git/sshj/SshjConfig.kt
index 4795765b..b43d0007 100644
--- a/app/src/main/java/app/passwordstore/util/git/sshj/SshjConfig.kt
+++ b/app/src/main/java/app/passwordstore/util/git/sshj/SshjConfig.kt
@@ -51,9 +51,11 @@ fun setUpBouncyCastleForSshj() {
logcat("setUpBouncyCastleForSshj", priority = VERBOSE) {
"JCE providers: ${Security.getProviders().joinToString { "${it.name} (${it.version})" }}"
}
- // Prevent sshj from forwarding all cryptographic operations to BC.
- SecurityUtils.setRegisterBouncyCastle(false)
+ // Prevent sshj from forwarding all cryptographic operations to BC, which cannot sign with
+ // Android Keystore keys. Order matters: since sshj 0.40, setSecurityProvider(null) also resets
+ // the register flag, so setting the flag first would be undone.
SecurityUtils.setSecurityProvider(null)
+ SecurityUtils.setRegisterBouncyCastle(false)
}
private object LogcatLoggerFactory : LoggerFactory {
diff --git a/app/src/test/java/app/passwordstore/util/git/sshj/Ed25519KeyPairTest.kt b/app/src/test/java/app/passwordstore/util/git/sshj/Ed25519KeyPairTest.kt
new file mode 100644
index 00000000..34acae18
--- /dev/null
+++ b/app/src/test/java/app/passwordstore/util/git/sshj/Ed25519KeyPairTest.kt
@@ -0,0 +1,53 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.util.git.sshj
+
+import java.security.Signature
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+import net.schmizz.sshj.common.Buffer
+import net.schmizz.sshj.common.KeyType
+import org.bouncycastle.jce.provider.BouncyCastleProvider
+
+class Ed25519KeyPairTest {
+
+ private fun hex(s: String) = s.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
+
+ // RFC 8032, section 7.1, test 1. Wrapped keys store the 32-byte seed, so a seed must keep
+ // mapping to the same key pair across library changes.
+ private val seed = hex("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60")
+ private val publicKey = hex("d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a")
+
+ @Test
+ fun derivesTheRfc8032PublicKeyFromTheSeed() {
+ val keyPair = ed25519KeyPair(seed)
+ assertEquals(KeyType.ED25519, KeyType.fromKey(keyPair.public))
+ val sshBlob = Buffer.PlainBuffer().putPublicKey(keyPair.public).compactData
+ // ssh-ed25519 wire format: string "ssh-ed25519", then string <32-byte public key>.
+ assertContentEquals(publicKey, sshBlob.copyOfRange(sshBlob.size - 32, sshBlob.size))
+ }
+
+ @Test
+ fun privateKeySignsForThePublicKey() {
+ val keyPair = ed25519KeyPair(seed)
+ val message = "hardenedpass".encodeToByteArray()
+ val signature =
+ Signature.getInstance("Ed25519", BouncyCastleProvider()).run {
+ initSign(keyPair.private)
+ update(message)
+ sign()
+ }
+ val verified =
+ Signature.getInstance("Ed25519", BouncyCastleProvider()).run {
+ initVerify(keyPair.public)
+ update(message)
+ verify(signature)
+ }
+ assertTrue(verified)
+ }
+}
diff --git a/app/src/test/java/app/passwordstore/util/git/sshj/SshjConfigTest.kt b/app/src/test/java/app/passwordstore/util/git/sshj/SshjConfigTest.kt
new file mode 100644
index 00000000..af6eee47
--- /dev/null
+++ b/app/src/test/java/app/passwordstore/util/git/sshj/SshjConfigTest.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.util.git.sshj
+
+import kotlin.test.Test
+import kotlin.test.assertNull
+import net.schmizz.sshj.common.SecurityUtils
+
+class SshjConfigTest {
+
+ // Android Keystore keys have no encoding, so Bouncy Castle cannot sign with them. sshj must
+ // leave provider selection to the JCA, which hands Keystore keys to the AndroidKeyStore
+ // provider.
+ @Test
+ fun sshjDoesNotForceBouncyCastle() {
+ setUpBouncyCastleForSshj()
+ assertNull(SecurityUtils.getSecurityProvider())
+ }
+}