diff options
Diffstat (limited to 'app/src')
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()) + } +} |
