aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md6
-rw-r--r--app/build.gradle.kts1
-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
-rw-r--r--gradle/libs.versions.toml3
7 files changed, 102 insertions, 16 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a3f17d2f..62469d96 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,12 @@
All notable changes to this project will be documented in this file.
+## [Unreleased]
+
+### Changed
+
+- Updated sshj to 0.41.2, which drops the unmaintained `net.i2p.crypto:eddsa` library; Ed25519 keys now go through Bouncy Castle. Existing generated Ed25519 keys keep working
+
## [0.1.3] - 2026-11-02
### Changed
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index e04126cd..68fc7bad 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -80,7 +80,6 @@ dependencies {
implementation(libs.aps.sublimeFuzzy)
implementation(libs.aps.zxingAndroidEmbedded)
- implementation(libs.thirdparty.eddsa)
implementation(libs.thirdparty.fastscroll)
implementation(libs.thirdparty.flowbinding.android)
implementation(libs.thirdparty.jgit) {
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())
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 7a7398a7..c3cdcf35 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -72,7 +72,6 @@ thirdparty-bouncycastle-bcutil = { module = "org.bouncycastle:bcutil-jdk18on", v
# noinspection GradleDependency
thirdparty-commons_codec = "commons-codec:commons-codec:1.14"
thirdparty-compose-lints = "com.slack.lint.compose:compose-lint-checks:1.4.1"
-thirdparty-eddsa = "net.i2p.crypto:eddsa:0.3.0"
thirdparty-fastscroll = "me.zhanghai.android.fastscroll:library:1.3.0"
thirdparty-flowbinding-android = { module = "io.github.reactivecircus.flowbinding:flowbinding-android", version.ref = "flowbinding" }
# JGit upgrades also raise its minimum Java runtime requirements that we cannot satisfy on Android
@@ -86,7 +85,7 @@ thirdparty-modernAndroidPrefs = "de.maxr1998:modernandroidpreferences:2.4.0-beta
thirdparty-pgpainless = "org.pgpainless:pgpainless-core:1.7.8"
thirdparty-slack-lints = "com.slack.lint:slack-lint-checks:0.8.0"
thirdparty-slf4j-api = { module = "org.slf4j:slf4j-api", version = { strictly = "[1.7, 1.8[", prefer = "1.7.36" } }
-thirdparty-sshj = "com.hierynomus:sshj:0.39.0"
+thirdparty-sshj = "com.hierynomus:sshj:0.41.2"
thirdparty-uri = "com.eygraber:uri-kmp:0.0.18"
[bundles]