diff options
| author | Danilo M. <danix@danix.xyz> | 2026-11-02 19:28:09 +0100 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-11-02 19:28:09 +0100 |
| commit | bf07c157071bea82000850ee431917fd5983d96d (patch) | |
| tree | 42f674305e772554e23bcd04055369d7786c637c /app | |
| parent | a638af6fd58c767ed448134e6527c5c5f8429889 (diff) | |
| download | HardenedPass-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')
5 files changed, 95 insertions, 14 deletions
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()) + } +} |
