Repository: twisted/twisted

Issue:
ckeygen doesn't provide a default for the key file
|[<img alt="therve's avatar" src="https://avatars.githubusercontent.com/u/159137?s=50" width="50" height="50">](https://github.com/therve)| @therve reported|
|-|-|
|Trac ID|trac#6607|
|Type|enhancement|
|Created|2013-06-30 12:46:51Z|
|Branch|https://github.com/twisted/twisted/tree/ckeygen-default-file-6607-2|

For example:

```
$ ckeygen -p
Enter file in which the key is (/home/therve/.ssh/id_rsa):
```

It shows a file in parenthesis, but doesn't actually use that as a default value if nothing is specified. It should.

Attachments:

* [6607.patch](https://raw.githubusercontent.com/twisted/twistedmatrix.com-trac-attachments/trunk/ticket/1c7/1c7eb6b64f78e03c1fdd3d4455e126ef6a71e674/90f10d0cebc07b540378dea2d7c623b7239edea8.patch) (1535 bytes) - added by Saurabh on 2013-10-09 03:47:30Z - 
* [6607_2.patch](https://raw.githubusercontent.com/twisted/twistedmatrix.com-trac-attachments/trunk/ticket/1c7/1c7eb6b64f78e03c1fdd3d4455e126ef6a71e674/e6fc01ed0871bd67d7731d9188f04cc44261313e.patch) (5052 bytes) - added by Saurabh on 2013-11-07 13:33:47Z - 
* [6607_3.patch](https://raw.githubusercontent.com/twisted/twistedmatrix.com-trac-attachments/trunk/ticket/1c7/1c7eb6b64f78e03c1fdd3d4455e126ef6a71e674/1dc21ba24ea76dfc2dc75d68e65c2791d282abb4.patch) (5063 bytes) - added by Saurabh on 2013-11-09 04:52:25Z - 

<details><summary>Searchable metadata</summary>

```
trac-id__6607 6607
type__enhancement enhancement
reporter__therve therve
priority__low low
milestone__ 
branch__branches_ckeygen_default_file_6607_2 branches/ckeygen-default-file-6607-2
branch_author__lvh__glyph lvh, glyph
status__new new
resolution__ 
component__conch conch
keywords__easy easy
time__1372596411000000 1372596411000000
changetime__1423033410714556 1423033410714556
version__None None
owner__ioggstream ioggstream
cc__z3p
```
</details>



Discussion:
|<img alt="ioggstream's avatar" src="https://avatars.githubusercontent.com/u/0?s=50" width="50" height="50"><a name="note_18"></a>|ioggstream set owner to @wallrj|
|-|-|

Fixing proposal with a different approach from the above.

1- parsing and raw_input is done inside the GeneralOption class

2- key generation is done in a single function using a di(ct)spatch-table to pick the right function

3- added parsing tests

https://github.com/ioggstream/twisted/commit/7316fc008e22621e046aad8a2b8ede751c97ca7e
|[<img alt="glyph's avatar" src="https://avatars.githubusercontent.com/u/716529?s=50" width="50" height="50">](https://github.com/glyph)<a name="note_21"></a>|@glyph set owner to ioggstream|
|-|-|

Hi ioggstream,

Thanks for picking this up.  I am sorry about the long turnaround; we are, as always, endeavoring to improve round-trip-time for code reviews.

I've [run your code through the buildbot](https://buildbot.twistedmatrix.com/boxes-supported?branch=/branches/ckeygen-default-file-6607-2) and there are numerous issues that will need to be addressed; lots of coding-standard issues, some tests failures due to optional dependencies not being conditionally imported, and suchlike.  Please have a look at the build results and fix the errors; when you think they're fixed re-submit for review and I will endeavor to run them again promptly.

In addition:

  1. in `test_examples` you should probably just be using `self.fail` rather than `raise FailTest`.
  2. You've left in some commented-out code.  When you want to comment out some code, [just delete it](http://nedbatchelder.com/text/deleting-code.html).  Version control can always bring it back.
  3. You can just populate a default type with a default specification in the `Options` class; there's no need to have procedural code to initialize it.

I hope you'll have some time to look at this again at some point; let me know if you're still interested.

Thanks again for your contribution!
|[<img alt="lvh's avatar" src="https://avatars.githubusercontent.com/u/97816?s=50" width="50" height="50">](https://github.com/lvh)|@lvh removed owner|
|-|-|

|<img alt="Saurabh's avatar" src="https://avatars.githubusercontent.com/u/0?s=50" width="50" height="50">|Saurabh set owner to Saurabh|
|-|-|

|<img alt="Saurabh's avatar" src="https://avatars.githubusercontent.com/u/0?s=50" width="50" height="50">|Saurabh set owner to Saurabh<br>Saurabh set status to `new`|
|-|-|

|<img alt="Saurabh's avatar" src="https://avatars.githubusercontent.com/u/0?s=50" width="50" height="50">|Saurabh removed owner|
|-|-|

|[<img alt="lvh's avatar" src="https://avatars.githubusercontent.com/u/97816?s=50" width="50" height="50">](https://github.com/lvh)|@lvh set owner to @lvh|
|-|-|

|[<img alt="lvh's avatar" src="https://avatars.githubusercontent.com/u/97816?s=50" width="50" height="50">](https://github.com/lvh)<a name="note_10"></a>|@lvh set owner to Saurabh|
|-|-|

Hi saurabh,


Thanks for working on this some more! This is starting to look pretty good :)

A few minor nitpicks:

 - Please observe the coding standard, for example:
  - There should be whitespace after, but not before a comma
   - ckeygen.py L102 L123 L174
   - test_ckeygen.py L83 L162 L392
  - No spaces before and after ```=``` in keyword arguments
 - ```raw_input``` should definitely be an implementation detail since it's for testing only, so please prefix it with a ```_```
 - I can see how default_file becoming public API is good (so that the caller can make educated guesses for non-*nix systems), but given the previous point it should probably come before _raw_input
 - In the test, don't use a bare ```except``` but catch the exception classes you actually mean (or expect)
 - The new "get a filename" behavior is identical in all three affected functions, so it should probably be refactored.
 - The behavior is only tested for ```displayPublicKey```, but it's a change for ```changePassPhrase``` and ```printFingerprint``` as well.


Thanks again, and I look forward to merging this :)

lvh
|<img alt="Saurabh's avatar" src="https://avatars.githubusercontent.com/u/0?s=50" width="50" height="50">|Saurabh removed owner|
|-|-|

|[<img alt="lvh's avatar" src="https://avatars.githubusercontent.com/u/97816?s=50" width="50" height="50">](https://github.com/lvh)|@lvh set owner to @lvh|
|-|-|

|<img alt="sgrondahl's avatar" src="https://avatars.githubusercontent.com/u/0?s=50" width="50" height="50"><a name="note_2"></a>|sgrondahl set status to `closed`|
|-|-|

Fixed and submitted pull request -- commit b321af5c0251e3ef0bfdd87cdd1d52470d07e4ee.
|[<img alt="exarkun's avatar" src="https://avatars.githubusercontent.com/u/254565?s=50" width="50" height="50">](https://github.com/exarkun)<a name="note_3"></a>|@exarkun set status to `reopened`|
|-|-|

Hi sgrondahl.  Please leave tickets open until the fix is actually accepted.

What is b321af5c0251e3ef0bfdd87cdd1d52470d07e4ee?  Does it refer to a changeset somewhere on github?

|[<img alt="glyph's avatar" src="https://avatars.githubusercontent.com/u/716529?s=50" width="50" height="50">](https://github.com/glyph)<a name="note_19"></a>|@glyph commented|
|-|-|

(In [43817]) Branching to ckeygen-default-file-6607-2.
|[<img alt="glyph's avatar" src="https://avatars.githubusercontent.com/u/716529?s=50" width="50" height="50">](https://github.com/glyph)<a name="note_20"></a>|@glyph commented|
|-|-|

Branch from github integrated and committed to a branch, builds forced.
|<img alt="ioggstream's avatar" src="https://avatars.githubusercontent.com/u/0?s=50" width="50" height="50"><a name="note_22"></a>|ioggstream commented|
|-|-|

Hi glyph,

don't worry for the timing and thank you very much for yours. I will find some time to accomplish the mission.

I saw the bot logs and I need some hints:

  1. pylint: as my patch is regards only twisted.conch.ckeygen I'm supposed to take care only of the associated stuff, not on the all twisted.conch right? I don't want to mess with more files than necessary;
  1. pylintrc: I'm installing twistedchecker, so I'll be able to lint...
  1. commented code: got it, code removed, thx++;
  1. optional dependencies / conditional imports: they are actually present in twisted/conch/ssh/keys.py which I didn't modify: what should I do?
  1. test_examples file will be removed from my patch, as it's about twisted.names
  1. Option class default values, iirc I need some procedural code to respect old behavior.

Thx again for your feedback,
R.


|[<img alt="glyph's avatar" src="https://avatars.githubusercontent.com/u/716529?s=50" width="50" height="50">](https://github.com/glyph)<a name="note_23"></a>|@glyph commented|
|-|-|

Replying to [ioggstream](#note_22):
> Hi glyph,
> 
> don't worry for the timing and thank you very much for yours. I will find some time to accomplish the mission.
> 
> I saw the bot logs and I need some hints:
> 
>   1. pylint: as my patch is regards only twisted.conch.ckeygen I'm supposed to take care only of the associated stuff, not on the all twisted.conch right? I don't want to mess with more files than necessary;

The buildbot should only be reporting errors in your specific change.  If not, it's a twistedchecker bug, not your problem - you just need to file the bug and link it here so that any irrelevant warnings will pass review.  In this fix, don't even fix all the stuff in `ckeygen`, just make sure your code doesn't add any _new_ warnings.

>   1. pylintrc: I'm installing twistedchecker, so I'll be able to lint...
>   1. commented code: got it, code removed, thx++;
>   1. optional dependencies / conditional imports: they are actually present in twisted/conch/ssh/keys.py which I didn't modify: what should I do?

I believe the issue is that you added a test which is not properly skipped?

>   1. test_examples file will be removed from my patch, as it's about twisted.names

Great.

>   1. Option class default values, iirc I need some procedural code to respect old behavior.

You do need it to respect the old behavior according to [the relevant policy](https://twistedmatrix.com/trac/wiki/CompatibilityPolicy), but wouldn't adding a default do the same thing?

> Thx again for your feedback,
> R.

You're welcome; thank _you_ for your contribution :).

|[<img alt="lvh's avatar" src="https://avatars.githubusercontent.com/u/97816?s=50" width="50" height="50">](https://github.com/lvh)<a name="note_5"></a>|@lvh commented|
|-|-|

Hi Saurabh,


Thanks for working on this. This appears to be missing a test. I'm not entirely sure if the call to ```strip``` is necessary (but hey, it doesn't really hurt either). I think it'd probably be better on several lines, since the lines are longer than 78 now.

cheers
lvh
|<img alt="Saurabh's avatar" src="https://avatars.githubusercontent.com/u/0?s=50" width="50" height="50"><a name="note_12"></a>|Saurabh commented|
|-|-|

I have refactored the "get filename" part as suggested..and made other mentioned small changes.
|[<img alt="lvh's avatar" src="https://avatars.githubusercontent.com/u/97816?s=50" width="50" height="50">](https://github.com/lvh)<a name="note_14"></a>|@lvh commented|
|-|-|

(In [40615]) Branching to 'ckeygen-default-file-6607'
|[<img alt="lvh's avatar" src="https://avatars.githubusercontent.com/u/97816?s=50" width="50" height="50">](https://github.com/lvh)<a name="note_15"></a>|@lvh commented|
|-|-|

Hi again :)

There are some twistedchecker failures:

```
************* Module twisted.conch.scripts.ckeygen
W9208:102,0:_getFilename: Missing docstring
C0103:102,17:_getFilename: Invalid name "default_file" (should match ([a-z_][a-zA-Z0-9]*)$)
C0103:102,31:_getFilename: Invalid name "_raw_input" (should match ([a-z_][a-zA-Z0-9]*)$)
W9501:105,16:_getFilename: String formatting operations should always use a tuplefor non-mapping values
C0301:111,0: Line too long (82/79)
C0103:111,30:printFingerprint: Invalid name "default_file" (should match ([a-z_][a-zA-Z0-9]*)$)
C0103:111,60:printFingerprint: Invalid name "_raw_input" (should match ([a-z_][a-zA-Z0-9]*)$)
C0103:129,30:changePassPhrase: Invalid name "default_file" (should match ([a-z_][a-zA-Z0-9]*)$)
C0103:129,60:changePassPhrase: Invalid name "_raw_input" (should match ([a-z_][a-zA-Z0-9]*)$)
C0103:177,30:displayPublicKey: Invalid name "default_file" (should match ([a-z_][a-zA-Z0-9]*)$)
C0103:177,60:displayPublicKey: Invalid name "_raw_input" (should match ([a-z_][a-zA-Z0-9]*)$)
************* Module twisted.conch.test.test_ckeygen
W9011: 71,0: Blank line contains whitespace
W9010:152,0: Trailing whitespace found in the end of line
```

exarkun has reminded me of the argument that trying to name arguments with an underscore prefix is suboptimal. The short version of that argument (as I understand it) is that because you can call arguments named with an underscore without realizing they're private, the effect of "private" is gone. For anything else (like class, function or method names), you have to type the underscore, so you *know* you're doing something internal. (I think that the underscore still has value for documentation purposes; from what I understand, JP disagrees.)

The solution to this would be a private class _CKeygenCommands or something with methods:

 - _getFilename
 - printFingerprint
 - changePassPhrase
 - displayPublicKey

For testing, you instantiate one of those with a fake ```raw_input```. There would also be a global instance on the module level; then:

```
_theInstance = _CKeygenCommands(raw_input)
printFingerprint = _theInstance.printFingerprint
changePassPhrase = _theInstance.changePassPhrase
displayPublicKey = _theInstance.displayPublicKey
```

That way, public API doesn't change, but you still get your DI-able, testable thing.

cheers
lvh


Resolving patch:
diff --git a/src/twisted/conch/scripts/ckeygen.py b/src/twisted/conch/scripts/ckeygen.py
--- a/src/twisted/conch/scripts/ckeygen.py
+++ b/src/twisted/conch/scripts/ckeygen.py
@@ -9,10 +9,13 @@
 
 import getpass
 import os
+import platform
 import socket
 import sys
+from collections.abc import Callable
 from functools import wraps
 from imp import reload
+from typing import Any, Dict, Optional
 
 from twisted.conch.ssh import keys
 from twisted.python import failure, filepath, log, usage
@@ -118,7 +121,7 @@ def enumrepresentation(options):
         return options
     else:
         raise keys.BadFingerPrintFormat(
-            "Unsupported fingerprint format: {}".format(options["format"])
+            f"Unsupported fingerprint format: {options['format']}"
         )
 
 
@@ -202,45 +205,71 @@ def _defaultPrivateKeySubtype(keyType):
         return "PEM"
 
 
-def printFingerprint(options):
-    if not options["filename"]:
-        filename = os.path.expanduser("~/.ssh/id_rsa")
-        options["filename"] = input("Enter file in which the key is (%s): " % filename)
-    if os.path.exists(options["filename"] + ".pub"):
-        options["filename"] += ".pub"
+def _getKeyOrDefault(
+    options: Dict[Any, Any],
+    inputCollector: Optional[Callable] = None,
+    keyTypeName: str = "rsa",
+) -> str:
+    """
+    If C{options["filename"]} is None, prompt the user to enter a path
+    or attempt to set it to .ssh/id_rsa
+    @param options: command line options
+    @param inputCollector: dependency injection for testing
+    @param keyTypeName: key type or "rsa"
+    """
+    if inputCollector is None:
+        inputCollector = input
+    filename = options["filename"]
+    if not filename:
+        filename = os.path.expanduser(f"~/.ssh/id_{keyTypeName}")
+        if platform.system() == "Windows":
+            filename = os.path.expanduser(fR"%HOMEPATH %\.ssh\id_{keyTypeName}")
+        filename = (
+            inputCollector("Enter file in which the key is (%s): " % filename)
+            or filename
+        )
+    return str(filename)
+
+
+def printFingerprint(options: Dict[Any, Any]) -> None:
+    filename = _getKeyOrDefault(options)
+    if os.path.exists(filename + ".pub"):
+        filename += ".pub"
     options = enumrepresentation(options)
     try:
-        key = keys.Key.fromFile(options["filename"])
+        key = keys.Key.fromFile(filename)
         print(
             "%s %s %s"
             % (
                 key.size(),
                 key.fingerprint(options["format"]),
-                os.path.basename(options["filename"]),
+                os.path.basename(filename),
             )
         )
     except keys.BadKeyError:
         sys.exit("bad key")
+    except FileNotFoundError:
+        sys.exit(f"{filename} could not be opened, please specify a file.")
 
 
 def changePassPhrase(options):
-    if not options["filename"]:
-        filename = os.path.expanduser("~/.ssh/id_rsa")
-        options["filename"] = input("Enter file in which the key is (%s): " % filename)
+    filename = _getKeyOrDefault(options)
     try:
-        key = keys.Key.fromFile(options["filename"])
+        key = keys.Key.fromFile(filename)
     except keys.EncryptedKeyError:
         # Raised if password not supplied for an encrypted key
         if not options.get("pass"):
             options["pass"] = getpass.getpass("Enter old passphrase: ")
         try:
-            key = keys.Key.fromFile(options["filename"], passphrase=options["pass"])
+            key = keys.Key.fromFile(filename, passphrase=options["pass"])
         except keys.BadKeyError:
             sys.exit("Could not change passphrase: old passphrase error")
         except keys.EncryptedKeyError as e:
             sys.exit(f"Could not change passphrase: {e}")
     except keys.BadKeyError as e:
         sys.exit(f"Could not change passphrase: {e}")
+    except FileNotFoundError:
+        sys.exit(f"{filename} could not be opened, please specify a file.")
 
     if not options.get("newpass"):
         while 1:
@@ -268,22 +297,22 @@ def changePassPhrase(options):
     except (keys.EncryptedKeyError, keys.BadKeyError) as e:
         sys.exit(f"Could not change passphrase: {e}")
 
-    with open(options["filename"], "wb") as fd:
+    with open(filename, "wb") as fd:
         fd.write(newkeydata)
 
     print("Your identification has been saved with the new passphrase.")
 
 
 def displayPublicKey(options):
-    if not options["filename"]:
-        filename = os.path.expanduser("~/.ssh/id_rsa")
-        options["filename"] = input("Enter file in which the key is (%s): " % filename)
+    filename = _getKeyOrDefault(options)
     try:
-        key = keys.Key.fromFile(options["filename"])
+        key = keys.Key.fromFile(filename)
+    except FileNotFoundError:
+        sys.exit(f"{filename} could not be opened, please specify a file.")
     except keys.EncryptedKeyError:
         if not options.get("pass"):
             options["pass"] = getpass.getpass("Enter passphrase: ")
-        key = keys.Key.fromFile(options["filename"], passphrase=options["pass"])
+        key = keys.Key.fromFile(filename, passphrase=options["pass"])
     displayKey = key.public().toString("openssh").decode("ascii")
     print(displayKey)
 
@@ -297,29 +326,36 @@ def _inputSaveFile(prompt: str) -> str:
     return input(prompt)
 
 
-def _saveKey(key, options):
+def _saveKey(
+    key: keys.Key,
+    options: Dict[Any, Any],
+    inputCollector: Optional[Callable] = None,
+) -> None:
     """
     Persist a SSH key on local filesystem.
 
     @param key: Key which is persisted on local filesystem.
-    @type key: C{keys.Key} implementation.
 
     @param options:
-    @type options: L{dict}
+
+    @param inputCollector: Dependency injection for testing.
     """
+    if inputCollector is None:
+        inputCollector = input
     KeyTypeMapping = {"EC": "ecdsa", "Ed25519": "ed25519", "RSA": "rsa", "DSA": "dsa"}
     keyTypeName = KeyTypeMapping[key.type()]
-    if not options["filename"]:
-        defaultPath = os.path.expanduser(f"~/.ssh/id_{keyTypeName}")
+    filename = options["filename"]
+    if not filename:
+        defaultPath = _getKeyOrDefault(options, inputCollector, keyTypeName)
         newPath = _inputSaveFile(
             f"Enter file in which to save the key ({defaultPath}): "
         )
 
-        options["filename"] = newPath.strip() or defaultPath
+        filename = newPath.strip() or defaultPath
 
-    if os.path.exists(options["filename"]):
-        print("{} already exists.".format(options["filename"]))
-        yn = input("Overwrite (y/n)? ")
+    if os.path.exists(filename):
+        print(f"{filename} already exists.")
+        yn = inputCollector("Overwrite (y/n)? ")
         if yn[0].lower() != "y":
             sys.exit()
 
@@ -339,23 +375,24 @@ def _saveKey(key, options):
 
     comment = f"{getpass.getuser()}@{socket.gethostname()}"
 
-    filepath.FilePath(options["filename"]).setContent(
+    fp = filepath.FilePath(filename)
+    fp.setContent(
         key.toString(
             "openssh",
             subtype=options["private-key-subtype"],
             passphrase=options["pass"],
         )
     )
-    os.chmod(options["filename"], 33152)
+    fp.chmod(0o100600)
 
-    filepath.FilePath(options["filename"] + ".pub").setContent(
+    filepath.FilePath(filename + ".pub").setContent(
         key.public().toString("openssh", comment=comment)
     )
     options = enumrepresentation(options)
 
-    print("Your identification has been saved in {}".format(options["filename"]))
-    print("Your public key has been saved in {}.pub".format(options["filename"]))
-    print("The key fingerprint in {} is:".format(options["format"]))
+    print(f"Your identification has been saved in {filename}")
+    print(f"Your public key has been saved in {filename}.pub")
+    print(f"The key fingerprint in {options['format']} is:")
     print(key.fingerprint(options["format"]))
 
 


Test patch:
diff --git a/src/twisted/conch/test/test_ckeygen.py b/src/twisted/conch/test/test_ckeygen.py
--- a/src/twisted/conch/test/test_ckeygen.py
+++ b/src/twisted/conch/test/test_ckeygen.py
@@ -6,6 +6,7 @@
 """
 
 import getpass
+import os
 import subprocess
 import sys
 from io import StringIO
@@ -23,6 +24,7 @@
 
 if requireModule("cryptography") and requireModule("pyasn1"):
     from twisted.conch.scripts.ckeygen import (
+        _getKeyOrDefault,
         _saveKey,
         changePassPhrase,
         displayPublicKey,
@@ -172,6 +174,19 @@ def test_printFingerprintBadFingerPrintFormat(self):
             "Unsupported fingerprint format: sha-base64", em.exception.args[0]
         )
 
+    def test_printFingerprintSuffixAppended(self) -> None:
+        """
+        L{printFingerprint} checks if the filename with the  '.pub' suffix
+        exists in ~/.ssh.
+        """
+        filename = self.mktemp()
+        FilePath(filename + ".pub").setContent(publicRSA_openssh)
+        printFingerprint({"filename": filename, "format": "md5-hex"})
+        self.assertEqual(
+            self.stdout.getvalue(),
+            "2048 85:25:04:32:58:55:96:9f:57:ee:fb:a8:1a:ea:69:da temp.pub\n",
+        )
+
     def test_saveKey(self):
         """
         L{_saveKey} writes the private and public parts of a key to two
@@ -345,17 +360,43 @@ def test_saveKeyNoFilename(self):
         base = FilePath(self.mktemp())
         base.makedirs()
         keyPath = base.child("custom_key").path
+        input_prompts = []
 
         import twisted.conch.scripts.ckeygen
 
+        def mock_input(*args):
+            return input_prompts.append("")
+
         self.patch(twisted.conch.scripts.ckeygen, "_inputSaveFile", lambda _: keyPath)
         key = Key.fromString(privateRSA_openssh)
-        _saveKey(key, {"filename": None, "no-passphrase": True, "format": "md5-hex"})
+        _saveKey(
+            key,
+            {"filename": None, "no-passphrase": True, "format": "md5-hex"},
+            mock_input,
+        )
 
         persistedKeyContent = base.child("custom_key").getContent()
         persistedKey = key.fromString(persistedKeyContent, None, b"")
         self.assertEqual(key, persistedKey)
 
+    def test_saveKeyFileExists(self) -> None:
+        """
+        When the specified file exists, it will ask the user for confirmation
+        before overwriting.
+        """
+
+        def mock_input(*args):
+            return ["n"]
+
+        base = FilePath(self.mktemp())
+        base.makedirs()
+        keyPath = base.child("custom_key").path
+
+        self.patch(os.path, "exists", lambda _: True)
+        key = Key.fromString(privateRSA_openssh)
+        options = {"filename": keyPath, "no-passphrase": True, "format": "md5-hex"}
+        self.assertRaises(SystemExit, _saveKey, key, options, mock_input)
+
     def test_saveKeySubtypeV1(self):
         """
         L{_saveKey} can be told to write the new private key file in OpenSSH
@@ -628,3 +669,52 @@ def test_changePassphraseSubtypeV1(self):
         self.assertTrue(
             privateKeyContent.startswith(b"-----BEGIN OPENSSH PRIVATE KEY-----\n")
         )
+
+    def test_useDefaultForKey(self) -> None:
+        """
+        L{options} will default to "~/.ssh/id_rsa" if the user doesn't
+        specify a key.
+        """
+        input_prompts = []
+
+        def mock_input(*args):
+            return input_prompts.append("")
+
+        options = {"filename": ""}
+
+        filename = _getKeyOrDefault(options, mock_input)
+        self.assertEqual(
+            options["filename"],
+            "",
+        )
+        # Resolved path is an RSA key inside .ssh dir.
+        self.assertTrue(filename.endswith(os.path.join(".ssh", "id_rsa")))
+        # The user is prompted once to enter the path, since no path was
+        # provided via CLI.
+        self.assertEqual(1, len(input_prompts))
+        self.assertEqual([""], input_prompts)
+
+    def test_displayPublicKeyHandleFileNotFound(self) -> None:
+        """
+        Ensure FileNotFoundError is handled, whether the user has supplied
+        a bad path, or has no key at the default path.
+        """
+        options = {"filename": "/foo/bar"}
+        exc = self.assertRaises(SystemExit, displayPublicKey, options)
+        self.assertIn("could not be opened, please specify a file.", exc.args[0])
+
+    def test_changePassPhraseHandleFileNotFound(self) -> None:
+        """
+        Ensure FileNotFoundError is handled for an invalid filename.
+        """
+        options = {"filename": "/foo/bar"}
+        exc = self.assertRaises(SystemExit, changePassPhrase, options)
+        self.assertIn("could not be opened, please specify a file.", exc.args[0])
+
+    def test_printFingerprintHandleFileNotFound(self) -> None:
+        """
+        Ensure FileNotFoundError is handled for an invalid filename.
+        """
+        options = {"filename": "/foo/bar", "format": "md5-hex"}
+        exc = self.assertRaises(SystemExit, printFingerprint, options)
+        self.assertIn("could not be opened, please specify a file.", exc.args[0])