diff options
| -rw-r--r-- | CHANGELOG.md | 6 | ||||
| -rw-r--r-- | app/build.gradle.kts | 1 | ||||
| -rw-r--r-- | app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt | 27 | ||||
| -rw-r--r-- | app/src/main/java/app/passwordstore/util/git/sshj/SshjConfig.kt | 6 | ||||
| -rw-r--r-- | app/src/test/java/app/passwordstore/util/git/sshj/Ed25519KeyPairTest.kt | 53 | ||||
| -rw-r--r-- | app/src/test/java/app/passwordstore/util/git/sshj/SshjConfigTest.kt | 22 | ||||
| -rw-r--r-- | gradle/libs.versions.toml | 3 |
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] |
