Skip to content

feat(ssh): support passphrase-encrypted OpenSSH keys - #590

Open
ElCruncharino wants to merge 1 commit into
android-password-store:mainfrom
ElCruncharino:feature/encrypted-ssh-key-import
Open

feat(ssh): support passphrase-encrypted OpenSSH keys#590
ElCruncharino wants to merge 1 commit into
android-password-store:mainfrom
ElCruncharino:feature/encrypted-ssh-key-import

Conversation

@ElCruncharino

@ElCruncharino ElCruncharino commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

#547 added ssh-ed25519 and ssh-rsa support, but only for unencrypted keys. This adds the common case: a passphrase-encrypted key from ssh-keygen.

OpenSSH derives the key from your passphrase with bcrypt_pbkdf (not textbook bcrypt), then encrypts with AES-CTR or AES-CBC. Neither bcrypt_pbkdf nor its underlying Blowfish schedule is in BouncyCastle, so BcryptPbkdf.kt/Blowfish.kt are ported from OpenBSD's own C reference, with constants and known-answer vectors pulled from that source by script instead of hand-typed.

SshKey.parseIdentity gets a passphrase: ByteArray overload. A wrong passphrase throws IncorrectPassphraseException (a subtype of InvalidSshKeyException) instead of a generic checksum error. KDF rounds are capped at 1000, the same precedent as ScryptIdentity.maxWorkFactor.

Tested against OpenBSD's own known-answer vectors, real ssh-keygen output, and two of golang.org/x/crypto/ssh's encrypted-key fixtures, the same corpus age's own SSH support tests against.

Notes for review:

  • New public API: SshKey.parseIdentity(privateKey, passphrase), IncorrectPassphraseException. In the kage.api diff.
  • InvalidSshKeyException is now open so IncorrectPassphraseException can extend it.
  • Verified with ./gradlew test checkKotlinAbi animalsnifferMain spotlessCheck.

Adds bcrypt_pbkdf and the Blowfish schedule it needs (neither is in
BouncyCastle), ported from OpenBSD's reference C. Verified against
OpenBSD's own KAT vectors, golang.org/x/crypto/ssh's own encrypted-key
fixtures, and real ssh-keygen/age round trips.

Constants live in their own file (BlowfishConstants.kt) so the actual
logic in Blowfish.kt stays a normal size.

A wrong passphrase now throws IncorrectPassphraseException (a subtype
of InvalidSshKeyException) instead of the generic checksum-mismatch
error, so callers can show a useful message. bcrypt kdf rounds are
capped at 1000, matching the existing ScryptIdentity.maxWorkFactor
precedent, so a hostile key file can't force an unbounded-cost decrypt.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant