Skip to content

Commit a70082f

Browse files
paninaroaqk
andauthored
macOS keyring.yaml support (#8292)
* Added 'service' as a Keychain ctor param. Removed 'testing' * Detect existing keys in the Mac Keychain * Fix to allow migration of keys on macOS * Added dump_keyring.py tool to show decrypted contents of keyring.yaml * Prompt to save passphrase to macOS keychain * Master passphrase retrieval/removal from the macOS Keychain. Fixed typos. * Warn if errSecInteractionNotAllowed is detected when accessing the macOS Keychain * Fixed file_keyring synchronization test failures on macOS. Fixed sporadic test failures on macOS when fsevents are delivered for the keyring after deletion. TempKeyring-based tests now patch supports_os_passphrase_storage() to return False. * TempKeyring mocks-out legacy_keyring setup to allow tests to succeed on macOS (which could find existing keys in the Keychain) * Fixed pylint error * Use with_name instead of with_stem (which is new to Python 3.9) * Fixed keychain tests that started prompting for the keyring passphrase. * Fixed LGTM issues * Re-added the cleaning up temp keychain statement. This is being removed in a separate PR. * Linter fixes * Fixed keyring assignment on macOS when passphrase support is disabled. * Include 'can_save_passphrase' flag in keyring_status response * More linter fixes * Fixed determination of the user_passphrase_is_set flag. This was returning true for a newly created keyring without any keys (or passphrase set) * Removed the tidy_passphrase function per feedback * Added some comments based on feedback * Update chia/cmds/passphrase_funcs.py Co-authored-by: Adam Kelly <[email protected]> Co-authored-by: Adam Kelly <[email protected]>
1 parent b5537fc commit a70082f

File tree

12 files changed

+434
-140
lines changed

12 files changed

+434
-140
lines changed

chia/cmds/passphrase_funcs.py

Lines changed: 52 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
import sys
33

44
from chia.daemon.client import acquire_connection_to_daemon
5-
from chia.util.keychain import Keychain, obtain_current_passphrase
5+
from chia.util.keychain import Keychain, obtain_current_passphrase, supports_os_passphrase_storage
66
from chia.util.keyring_wrapper import DEFAULT_PASSPHRASE_IF_NO_MASTER_PASSPHRASE
7+
from chia.util.misc import prompt_yes_no
78
from chia.util.ws_message import WsRpcMessage
89
from getpass import getpass
910
from io import TextIOWrapper
@@ -42,33 +43,56 @@ def verify_passphrase_meets_requirements(
4243
raise Exception("Unexpected passphrase verification case")
4344

4445

45-
def tidy_passphrase(passphrase: str) -> str:
46-
"""
47-
Perform any string processing we want to apply to the entered passphrase.
48-
Currently we strip leading/trailing whitespace.
49-
"""
50-
return passphrase.strip()
46+
def prompt_to_save_passphrase() -> bool:
47+
save: bool = False
48+
49+
try:
50+
if supports_os_passphrase_storage():
51+
location: Optional[str] = None
5152

53+
if sys.platform == "darwin":
54+
location = "macOS Keychain"
5255

53-
def prompt_for_new_passphrase() -> str:
56+
if location is None:
57+
raise ValueError("OS-specific credential store not specified")
58+
59+
print(
60+
"\n"
61+
"Your passphrase can be stored in your system's secure credential store. "
62+
"Other Chia processes will be able to access your keys without prompting for your passphrase."
63+
)
64+
save = prompt_yes_no(f"Would you like to save your passphrase to the {location} (y/n) ")
65+
66+
except Exception as e:
67+
print(f"Caught exception: {e}")
68+
return False
69+
70+
return save
71+
72+
73+
def prompt_for_new_passphrase() -> Tuple[str, bool]:
5474
min_length: int = Keychain.minimum_passphrase_length()
5575
if min_length > 0:
5676
n = min_length
5777
print(f"\nPassphrases must be {n} or more characters in length") # lgtm [py/clear-text-logging-sensitive-data]
5878
while True:
59-
passphrase = tidy_passphrase(getpass("New Passphrase: "))
60-
confirmation = tidy_passphrase(getpass("Confirm Passphrase: "))
79+
passphrase: str = getpass("New Passphrase: ")
80+
confirmation: str = getpass("Confirm Passphrase: ")
81+
save_passphrase: bool = False
6182

6283
valid_passphrase, error_msg = verify_passphrase_meets_requirements(passphrase, confirmation)
6384

6485
if valid_passphrase:
65-
return passphrase
86+
if supports_os_passphrase_storage():
87+
save_passphrase = prompt_to_save_passphrase()
88+
89+
return passphrase, save_passphrase
6690
elif error_msg:
6791
print(f"{error_msg}\n") # lgtm [py/clear-text-logging-sensitive-data]
6892

6993

7094
def read_passphrase_from_file(passphrase_file: TextIOWrapper) -> str:
71-
passphrase = tidy_passphrase(passphrase_file.read())
95+
passphrase = passphrase_file.read()
7296
passphrase_file.close()
7397
return passphrase
7498

@@ -82,14 +106,18 @@ def initialize_passphrase() -> None:
82106
# We'll rely on Keyring initialization to leverage the cached passphrase for
83107
# bootstrapping the keyring encryption process
84108
print("Setting keyring passphrase")
85-
passphrase = None
109+
passphrase: Optional[str] = None
110+
# save_passphrase indicates whether the passphrase should be saved in the
111+
# macOS Keychain or Windows Credential Manager
112+
save_passphrase: bool = False
113+
86114
if Keychain.has_cached_passphrase():
87115
passphrase = Keychain.get_cached_master_passphrase()
88116

89117
if not passphrase or passphrase == default_passphrase():
90-
passphrase = prompt_for_new_passphrase()
118+
passphrase, save_passphrase = prompt_for_new_passphrase()
91119

92-
Keychain.set_master_passphrase(current_passphrase=None, new_passphrase=passphrase)
120+
Keychain.set_master_passphrase(current_passphrase=None, new_passphrase=passphrase, save_passphrase=save_passphrase)
93121

94122

95123
def set_or_update_passphrase(passphrase: Optional[str], current_passphrase: Optional[str]) -> bool:
@@ -106,17 +134,21 @@ def set_or_update_passphrase(passphrase: Optional[str], current_passphrase: Opti
106134
print(f"Unable to confirm current passphrase: {e}")
107135
sys.exit(1)
108136

109-
success = False
110-
new_passphrase = passphrase
137+
success: bool = False
138+
new_passphrase: Optional[str] = passphrase
139+
save_passphrase: bool = False
140+
111141
try:
112142
# Prompt for the new passphrase, if necessary
113-
if not new_passphrase:
114-
new_passphrase = prompt_for_new_passphrase()
143+
if new_passphrase is None:
144+
new_passphrase, save_passphrase = prompt_for_new_passphrase()
115145

116146
if new_passphrase == current_passphrase:
117147
raise ValueError("passphrase is unchanged")
118148

119-
Keychain.set_master_passphrase(current_passphrase=current_passphrase, new_passphrase=new_passphrase)
149+
Keychain.set_master_passphrase(
150+
current_passphrase=current_passphrase, new_passphrase=new_passphrase, save_passphrase=save_passphrase
151+
)
120152
success = True
121153
except Exception as e:
122154
print(f"Unable to set or update passphrase: {e}")

chia/daemon/keychain_proxy.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ def __init__(
5555
ssl_context: Optional[ssl.SSLContext] = None,
5656
local_keychain: Optional[Keychain] = None,
5757
user: str = None,
58-
testing: bool = False,
58+
service: str = None,
5959
):
6060
self.log = log
6161
if local_keychain:
@@ -65,7 +65,7 @@ def __init__(
6565
else:
6666
self.keychain = None # type: ignore
6767
self.keychain_user = user
68-
self.keychain_testing = testing
68+
self.keychain_service = service
6969
super().__init__(uri or "", ssl_context)
7070

7171
def use_local_keychain(self) -> bool:
@@ -81,9 +81,9 @@ def format_request(self, command: str, data: Dict[str, Any]) -> WsRpcMessage:
8181
if data is None:
8282
data = {}
8383

84-
if self.keychain_user or self.keychain_testing:
84+
if self.keychain_user or self.keychain_service:
8585
data["kc_user"] = self.keychain_user
86-
data["kc_testing"] = self.keychain_testing
86+
data["kc_service"] = self.keychain_service
8787

8888
return super().format_request(command, data)
8989

@@ -304,14 +304,14 @@ async def connect_to_keychain(
304304
ssl_context: Optional[ssl.SSLContext],
305305
log: logging.Logger,
306306
user: str = None,
307-
testing: bool = False,
307+
service: str = None,
308308
) -> KeychainProxy:
309309
"""
310310
Connect to the local daemon.
311311
"""
312312

313313
client = KeychainProxy(
314-
uri=f"wss://{self_hostname}:{daemon_port}", ssl_context=ssl_context, log=log, user=user, testing=testing
314+
uri=f"wss://{self_hostname}:{daemon_port}", ssl_context=ssl_context, log=log, user=user, service=service
315315
)
316316
# Connect to the service if the proxy isn't using a local keychain
317317
if not client.use_local_keychain():
@@ -320,7 +320,11 @@ async def connect_to_keychain(
320320

321321

322322
async def connect_to_keychain_and_validate(
323-
root_path: Path, log: logging.Logger, *, user: str = None, testing: bool = False
323+
root_path: Path,
324+
log: logging.Logger,
325+
*,
326+
user: str = None,
327+
service: str = None,
324328
) -> Optional[KeychainProxy]:
325329
"""
326330
Connect to the local daemon and do a ping to ensure that something is really
@@ -334,7 +338,7 @@ async def connect_to_keychain_and_validate(
334338
ca_key_path = root_path / net_config["private_ssl_ca"]["key"]
335339
ssl_context = ssl_context_for_client(ca_crt_path, ca_key_path, crt_path, key_path, log=log)
336340
connection = await connect_to_keychain(
337-
net_config["self_hostname"], net_config["daemon_port"], ssl_context, log, user, testing
341+
net_config["self_hostname"], net_config["daemon_port"], ssl_context, log, user, service
338342
)
339343

340344
# If proxying to a local keychain, don't attempt to ping

chia/daemon/keychain_server.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,22 +36,22 @@ def __init__(self):
3636

3737
def get_keychain_for_request(self, request: Dict[str, Any]):
3838
"""
39-
Keychain instances can have a user and testing flag associated with them.
39+
Keychain instances can have user and service strings associated with them.
4040
The keychain backends ultimately point to the same data stores, but the user
41-
and testing flags are used to partition those data stores. We attempt to
42-
maintain a mapping of user/testing pairs to their corresponding Keychain.
41+
and service strings are used to partition those data stores. We attempt to
42+
maintain a mapping of user/service pairs to their corresponding Keychain.
4343
"""
4444
keychain = None
4545
user = request.get("kc_user", self._default_keychain.user)
46-
testing = request.get("kc_testing", self._default_keychain.testing)
47-
if user == self._default_keychain.user and testing == self._default_keychain.testing:
46+
service = request.get("kc_service", self._default_keychain.service)
47+
if user == self._default_keychain.user and service == self._default_keychain.service:
4848
keychain = self._default_keychain
4949
else:
50-
key = (user or "unnamed") + ("test" if testing else "")
50+
key = (user or "unnamed") + (service or "")
5151
if key in self._alt_keychains:
5252
keychain = self._alt_keychains[key]
5353
else:
54-
keychain = Keychain(user=user, testing=testing)
54+
keychain = Keychain(user=user, service=service)
5555
self._alt_keychains[key] = keychain
5656
return keychain
5757

chia/daemon/server.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
KeyringRequiresMigration,
3232
passphrase_requirements,
3333
supports_keyring_passphrase,
34+
supports_os_passphrase_storage,
3435
)
3536
from chia.util.path import mkdir
3637
from chia.util.service_groups import validate_service
@@ -341,14 +342,16 @@ async def is_keyring_locked(self) -> Dict[str, Any]:
341342

342343
async def keyring_status(self) -> Dict[str, Any]:
343344
passphrase_support_enabled: bool = supports_keyring_passphrase()
344-
user_passphrase_is_set: bool = not using_default_passphrase()
345+
can_save_passphrase: bool = supports_os_passphrase_storage()
346+
user_passphrase_is_set: bool = Keychain.has_master_passphrase() and not using_default_passphrase()
345347
locked: bool = Keychain.is_keyring_locked()
346348
needs_migration: bool = Keychain.needs_migration()
347349
requirements: Dict[str, Any] = passphrase_requirements()
348350
response: Dict[str, Any] = {
349351
"success": True,
350352
"is_keyring_locked": locked,
351353
"passphrase_support_enabled": passphrase_support_enabled,
354+
"can_save_passphrase": can_save_passphrase,
352355
"user_passphrase_is_set": user_passphrase_is_set,
353356
"needs_migration": needs_migration,
354357
"passphrase_requirements": requirements,

chia/util/dump_keyring.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
#!/usr/bin/env python3
2+
3+
import click
4+
import colorama
5+
import threading
6+
import yaml
7+
8+
from chia.cmds.passphrase_funcs import read_passphrase_from_file
9+
from chia.util.default_root import DEFAULT_KEYS_ROOT_PATH
10+
from chia.util.file_keyring import FileKeyring
11+
from chia.util.keyring_wrapper import DEFAULT_PASSPHRASE_IF_NO_MASTER_PASSPHRASE
12+
from cryptography.exceptions import InvalidTag
13+
from getpass import getpass
14+
from io import TextIOWrapper
15+
from pathlib import Path
16+
from typing import Any, Dict, Optional
17+
18+
DEFAULT_KEYRING_YAML = DEFAULT_KEYS_ROOT_PATH / "keyring.yaml"
19+
20+
21+
class DumpKeyring(FileKeyring): # lgtm [py/missing-call-to-init]
22+
def __init__(self, keyring_file: Path):
23+
self.keyring_path = keyring_file
24+
self.payload_cache = {}
25+
self.load_keyring_lock = threading.RLock()
26+
# We don't call super().__init__() to avoid side-effects
27+
28+
29+
def get_passphrase_prompt(keyring_file: str) -> str:
30+
prompt = (
31+
colorama.Fore.YELLOW
32+
+ colorama.Style.BRIGHT
33+
+ "(Unlock Keyring: "
34+
+ colorama.Fore.MAGENTA
35+
+ keyring_file
36+
+ colorama.Style.RESET_ALL
37+
+ colorama.Fore.YELLOW
38+
+ colorama.Style.BRIGHT
39+
+ ")"
40+
+ colorama.Style.RESET_ALL
41+
+ " Passphrase: "
42+
) # noqa: E501
43+
return prompt
44+
45+
46+
@click.command()
47+
@click.argument("keyring_file", nargs=1, default=DEFAULT_KEYRING_YAML)
48+
@click.option(
49+
"--full-payload", is_flag=True, default=False, help="Print the full keyring contents, including plaintext"
50+
)
51+
@click.option("--passphrase-file", type=click.File("r"), help="File or descriptor to read the passphrase from")
52+
@click.option("--pretty-print", is_flag=True, default=False)
53+
def dump(keyring_file, full_payload: bool, passphrase_file: Optional[TextIOWrapper], pretty_print: bool):
54+
passphrase: str = DEFAULT_PASSPHRASE_IF_NO_MASTER_PASSPHRASE
55+
prompt: str = get_passphrase_prompt(str(keyring_file))
56+
data: Dict[str, Any] = {}
57+
58+
print(f"Attempting to dump contents of keyring file: {keyring_file}\n")
59+
60+
if passphrase_file is not None:
61+
passphrase = read_passphrase_from_file(passphrase_file)
62+
63+
keyring = DumpKeyring(keyring_file)
64+
65+
if full_payload:
66+
keyring.load_outer_payload()
67+
data = keyring.outer_payload_cache
68+
69+
for i in range(5):
70+
try:
71+
keyring.load_keyring(passphrase)
72+
if len(data) > 0:
73+
data["data"] = keyring.payload_cache
74+
else:
75+
data = keyring.payload_cache
76+
77+
if pretty_print:
78+
print(yaml.dump(data))
79+
else:
80+
print(data)
81+
break
82+
except (ValueError, InvalidTag):
83+
passphrase = getpass(prompt)
84+
except Exception as e:
85+
print(f"Unhandled exception: {e}")
86+
break
87+
88+
89+
def main():
90+
colorama.init()
91+
dump() # pylint: disable=no-value-for-parameter
92+
93+
94+
if __name__ == "__main__":
95+
main()

0 commit comments

Comments
 (0)