Skip to content

Commit ce806af

Browse files
committed
new Mac keyring entries don't trust all Python
Previously, Mac keyring/keychain entries would trust Python, meaning that any Python app could read the credential. With this change, that only happens if the user follows up with "always allow" to the keychain dialog. This still is not perfect! But apparently the only way to do better on Mac would be to have a mycli shim binary. Based on dbcli/pgcli#1633 .
1 parent 48546a1 commit ce806af

8 files changed

Lines changed: 438 additions & 8 deletions

File tree

‎AGENTS.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,10 @@ A command line client for MySQL with auto-completion and syntax highlighting.
2525
├── mycli/config.py # configuration file readers and utilities
2626
├── mycli/constants.py # shared constants
2727
├── mycli/key_bindings.py # prompt_toolkit key binding utilities
28+
├── mycli/keyring_utils.py # saved credential utilities
2829
├── mycli/kubectl_tunnel.py # connection over kubectl tunnel
2930
├── mycli/lexer.py # extends `MySqlLexer` from Pygments
31+
├── mycli/macos_keychain.py # macOS Keychain credentials preserving access controls
3032
├── mycli/main.py # processes CLI arguments
3133
├── mycli/main_modes/ # main execution paths
3234
├── mycli/main_modes/batch.py # `--batch` mode

‎changelog.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
Upcoming (TBD)
22
==============
33

4+
Bug Fixes
5+
--------
6+
* New macOS keyring entries no longer automatically trust Python.
7+
8+
49
Documentation
510
--------
611
* Update features bullets in `README.md`.

‎mycli/client_connection.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
EMPTY_PASSWORD_FLAG_SENTINEL,
2323
ER_MUST_CHANGE_PASSWORD_LOGIN,
2424
)
25+
from mycli.keyring_utils import set_keyring_password
2526
from mycli.kubectl_tunnel import KubectlTunnel, KubectlTunnelError
2627
from mycli.packages.filepaths import guess_socket_location
2728
from mycli.packages.special.utils import format_connection_dsn
@@ -381,7 +382,7 @@ def _update_keyring(
381382
try:
382383
saved_pw = keyring.get_password(keyring_domain, keyring_identifier)
383384
if password != saved_pw or reset_keyring:
384-
keyring.set_password(keyring_domain, keyring_identifier, password)
385+
set_keyring_password(keyring_domain, keyring_identifier, password)
385386
click.secho(
386387
f'Password from source: "{password_source}" '
387388
f'saved to the system keyring at {keyring_domain}/{keyring_identifier}',

‎mycli/keyring_utils.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import sys
2+
3+
import keyring
4+
from keyring.backend import KeyringBackend
5+
6+
7+
def _set_password_with_backend(backend: KeyringBackend, service: str, account: str, password: str) -> None:
8+
from keyring.backends.macOS import Keyring
9+
10+
if type(backend) is Keyring:
11+
from mycli import macos_keychain
12+
13+
macos_keychain.set_password(service, account, password)
14+
else:
15+
backend.set_password(service, account, password)
16+
17+
18+
def set_keyring_password(service: str, account: str, password: str) -> None:
19+
if sys.platform != 'darwin':
20+
keyring.set_password(service, account, password)
21+
return
22+
23+
from keyring.backends.chainer import ChainerBackend # type: ignore[unreachable]
24+
25+
backend = keyring.get_keyring()
26+
if type(backend) is ChainerBackend:
27+
for candidate in backend.backends:
28+
try:
29+
_set_password_with_backend(candidate, service, account, password)
30+
return
31+
except NotImplementedError:
32+
continue
33+
else:
34+
_set_password_with_backend(backend, service, account, password)

‎mycli/macos_keychain.py‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import ctypes
2+
from typing import Any
3+
4+
from keyring.errors import KeyringLocked, PasswordSetError
5+
6+
7+
def _get_api() -> Any:
8+
from keyring.backends.macOS import api
9+
10+
return api
11+
12+
13+
def set_password(service: str, account: str, password: str) -> None:
14+
"""Preserve existing ACLs and create credentials without trusting Python."""
15+
api = _get_api()
16+
cf_array_create = api._found.CFArrayCreate
17+
cf_array_create.restype = ctypes.c_void_p
18+
cf_array_create.argtypes = (ctypes.c_void_p, ctypes.c_void_p, ctypes.c_long, ctypes.c_void_p)
19+
cf_data_create = api._found.CFDataCreate
20+
cf_data_create.restype = ctypes.c_void_p
21+
cf_data_create.argtypes = (ctypes.c_void_p, ctypes.c_void_p, ctypes.c_long)
22+
cf_release = api._found.CFRelease
23+
cf_release.restype = None
24+
cf_release.argtypes = (ctypes.c_void_p,)
25+
sec_access_create = api._sec.SecAccessCreate
26+
sec_access_create.restype = api.OS_status
27+
sec_access_create.argtypes = (ctypes.c_void_p, ctypes.c_void_p, ctypes.POINTER(ctypes.c_void_p))
28+
sec_item_update = api._sec.SecItemUpdate
29+
sec_item_update.restype = api.OS_status
30+
sec_item_update.argtypes = (ctypes.c_void_p, ctypes.c_void_p)
31+
32+
retained: list[ctypes.c_void_p] = []
33+
34+
def retain(value: int | ctypes.c_void_p | None, name: str) -> ctypes.c_void_p:
35+
if not value:
36+
raise RuntimeError(f'Unable to allocate Keychain {name}')
37+
pointer = value if isinstance(value, ctypes.c_void_p) else ctypes.c_void_p(value)
38+
retained.append(pointer)
39+
return pointer
40+
41+
try:
42+
service_value = retain(api.create_cf(service), 'service')
43+
account_value = retain(api.create_cf(account), 'account')
44+
encoded = password.encode('utf-8')
45+
password_value = retain(cf_data_create(None, ctypes.create_string_buffer(encoded), len(encoded)), 'password')
46+
search = retain(
47+
api.create_query(
48+
kSecClass=api.k_('kSecClassGenericPassword'),
49+
kSecAttrService=service_value,
50+
kSecAttrAccount=account_value,
51+
),
52+
'search',
53+
)
54+
attributes = retain(api.create_query(kSecValueData=password_value), 'attributes')
55+
status = sec_item_update(search, attributes)
56+
if status == api.error.item_not_found:
57+
# An empty array trusts nobody; NULL would trust the calling executable.
58+
trusted_apps = retain(cf_array_create(None, None, 0, None), 'access controls')
59+
access = ctypes.c_void_p()
60+
api.Error.raise_for_status(sec_access_create(service_value, trusted_apps, ctypes.byref(access)))
61+
retain(access, 'access')
62+
item = retain(
63+
api.create_query(
64+
kSecClass=api.k_('kSecClassGenericPassword'),
65+
kSecAttrService=service_value,
66+
kSecAttrAccount=account_value,
67+
kSecValueData=password_value,
68+
kSecAttrAccess=access,
69+
),
70+
'item',
71+
)
72+
status = api.SecItemAdd(item, None)
73+
api.Error.raise_for_status(status)
74+
except api.KeychainDenied as e:
75+
raise KeyringLocked(f"Can't store password on keychain: {e}") from e
76+
except api.Error as e:
77+
raise PasswordSetError(f"Can't store password on keychain: {e}") from e
78+
finally:
79+
for value in reversed(retained):
80+
cf_release(value)

‎test/pytests/test_client_connection.py‎

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -260,11 +260,14 @@ def fake_get_password(domain: str, identifier: str) -> str:
260260
return 'from-keyring'
261261

262262
monkeypatch.setattr(client_connection.keyring, 'get_password', fake_get_password)
263+
writes: list[tuple[str, str, str]] = []
264+
monkeypatch.setattr(client_connection, 'set_keyring_password', lambda *args: writes.append(args))
263265

264266
client.connect(user='alice', host='db', port=3307, use_keyring=True)
265267

266268
assert FakeSQLExecute.calls[-1]['password'] == 'from-keyring'
267269
assert get_password_calls == [('mycli.net', 'alice@db:3307:')]
270+
assert writes == []
268271

269272

270273
def test_connect_uses_mylogin_password_before_keyring(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -275,7 +278,7 @@ def test_connect_uses_mylogin_password_before_keyring(monkeypatch: pytest.Monkey
275278
'get_password',
276279
lambda domain, identifier: get_password_calls.append((domain, identifier)) or 'from-mylogin', # type: ignore[func-returns-value]
277280
)
278-
monkeypatch.setattr(client_connection.keyring, 'set_password', lambda *_args: None)
281+
monkeypatch.setattr(client_connection, 'set_keyring_password', lambda *_args: None)
279282

280283
client.connect(user='alice', host='db', port=3307, use_keyring=True)
281284

@@ -293,7 +296,7 @@ def test_connect_resolves_supplied_candidates_after_connection_details(monkeypat
293296
'get_password',
294297
lambda domain, identifier: keyring_calls.append((domain, identifier)) or 'from-dsn', # type: ignore[func-returns-value]
295298
)
296-
monkeypatch.setattr(client_connection.keyring, 'set_password', lambda *_args: None)
299+
monkeypatch.setattr(client_connection, 'set_keyring_password', lambda *_args: None)
297300

298301
client.connect(user='alice', host='db', port=3307, password_candidates=candidates, use_keyring=True)
299302

@@ -326,8 +329,8 @@ def test_connect_saves_selected_password_to_keyring(monkeypatch: pytest.MonkeyPa
326329
secho_calls: list[tuple[str, dict[str, Any]]] = []
327330
monkeypatch.setattr(client_connection.keyring, 'get_password', lambda *_args: 'old-secret')
328331
monkeypatch.setattr(
329-
client_connection.keyring,
330-
'set_password',
332+
client_connection,
333+
'set_keyring_password',
331334
lambda domain, identifier, password: set_password_calls.append((domain, identifier, password)),
332335
)
333336
monkeypatch.setattr(client_connection.click, 'secho', lambda message, **kwargs: secho_calls.append((message, kwargs)))
@@ -348,14 +351,54 @@ def test_connect_reports_keyring_save_error(monkeypatch: pytest.MonkeyPatch) ->
348351
def fail_set_password(*_args: Any) -> None:
349352
raise RuntimeError('locked')
350353

351-
monkeypatch.setattr(client_connection.keyring, 'set_password', fail_set_password)
354+
monkeypatch.setattr(client_connection, 'set_keyring_password', fail_set_password)
352355
monkeypatch.setattr(client_connection.click, 'secho', lambda message, **kwargs: secho_calls.append((message, kwargs)))
353356

354357
client.connect(user='alice', host='db', port=3307, password_candidates=candidates, use_keyring=True)
355358

356359
assert secho_calls == [('Password not saved to the system keyring: locked', {'err': True, 'fg': 'red'})]
357360

358361

362+
def test_connect_saves_fallback_password_when_keyring_is_empty(monkeypatch: pytest.MonkeyPatch) -> None:
363+
client = DummyClient()
364+
FakeSQLExecute.effects = [op_error(1045), None]
365+
writes: list[tuple[str, str, str]] = []
366+
monkeypatch.setattr(client_connection.keyring, 'get_password', lambda *_args: None)
367+
monkeypatch.setattr(client_connection.click, 'prompt', lambda *_args, **_kwargs: 'new-secret')
368+
monkeypatch.setattr(client_connection, 'set_keyring_password', lambda *args: writes.append(args))
369+
370+
client.connect(user='alice', host='db', port=3307, use_keyring=True)
371+
372+
assert writes == [('mycli.net', 'alice@db:3307:', 'new-secret')]
373+
374+
375+
def test_connect_does_not_rewrite_rejected_keyring_password(monkeypatch: pytest.MonkeyPatch) -> None:
376+
client = DummyClient()
377+
FakeSQLExecute.effects = [op_error(1045)]
378+
writes: list[tuple[str, str, str]] = []
379+
monkeypatch.setattr(client_connection.keyring, 'get_password', lambda *_args: 'old-secret')
380+
monkeypatch.setattr(client_connection, 'set_keyring_password', lambda *args: writes.append(args))
381+
382+
with pytest.raises(SystemExit):
383+
client.connect(user='alice', host='db', port=3307, use_keyring=True)
384+
385+
assert writes == []
386+
387+
388+
@pytest.mark.parametrize('reset', [False, True])
389+
def test_connect_only_rewrites_unchanged_password_for_reset(monkeypatch: pytest.MonkeyPatch, reset: bool) -> None:
390+
client = DummyClient()
391+
candidates = PasswordCandidates()
392+
candidates.add_value('literal', 'secret')
393+
writes: list[tuple[str, str, str]] = []
394+
monkeypatch.setattr(client_connection.keyring, 'get_password', lambda *_args: 'secret')
395+
monkeypatch.setattr(client_connection, 'set_keyring_password', lambda *args: writes.append(args))
396+
397+
client.connect(user='alice', host='db', port=3307, password_candidates=candidates, use_keyring=True, reset_keyring=reset)
398+
399+
assert writes == ([('mycli.net', 'alice@db:3307:', 'secret')] if reset else [])
400+
401+
359402
def test_connect_uses_ssh_jump_with_remote_socket(monkeypatch: pytest.MonkeyPatch) -> None:
360403
tunnel_calls: list[dict[str, Any]] = []
361404

@@ -485,8 +528,8 @@ def close(self) -> None:
485528
lambda domain, identifier: keyring_calls.append(('get', domain, identifier)),
486529
)
487530
monkeypatch.setattr(
488-
client_connection.keyring,
489-
'set_password',
531+
client_connection,
532+
'set_keyring_password',
490533
lambda domain, identifier, password: keyring_calls.append(('set', domain, identifier, password)),
491534
)
492535
password_candidates = PasswordCandidates()

‎test/pytests/test_keyring_utils.py‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
from unittest.mock import Mock
2+
3+
from keyring.backends.chainer import ChainerBackend
4+
from keyring.backends.macOS import Keyring
5+
import pytest
6+
7+
from mycli import keyring_utils, macos_keychain
8+
9+
10+
@pytest.fixture
11+
def native_write(monkeypatch: pytest.MonkeyPatch) -> Mock:
12+
write = Mock()
13+
monkeypatch.setattr(keyring_utils.sys, 'platform', 'darwin')
14+
monkeypatch.setattr(macos_keychain, 'set_password', write)
15+
return write
16+
17+
18+
@pytest.mark.parametrize('platform', ['linux', 'win32'])
19+
def test_other_platforms_use_configured_keyring(monkeypatch: pytest.MonkeyPatch, platform: str) -> None:
20+
write = Mock()
21+
monkeypatch.setattr(keyring_utils.sys, 'platform', platform)
22+
monkeypatch.setattr(keyring_utils.keyring, 'set_password', write)
23+
monkeypatch.setattr(keyring_utils.keyring, 'get_keyring', Mock(side_effect=AssertionError('Do not inspect backend')))
24+
25+
keyring_utils.set_keyring_password('mycli.net', 'account', 'secret')
26+
27+
write.assert_called_once_with('mycli.net', 'account', 'secret')
28+
29+
30+
@pytest.mark.parametrize('chained', [False, True])
31+
def test_native_backend_uses_restricted_writer(monkeypatch: pytest.MonkeyPatch, native_write: Mock, chained: bool) -> None:
32+
backend = Keyring()
33+
monkeypatch.setattr(backend, 'set_password', Mock(side_effect=AssertionError('Unsafe native write')))
34+
monkeypatch.setattr(ChainerBackend, 'backends', [backend])
35+
monkeypatch.setattr(keyring_utils.keyring, 'get_keyring', lambda: ChainerBackend() if chained else backend)
36+
37+
keyring_utils.set_keyring_password('mycli.net', 'account', 'secret')
38+
39+
native_write.assert_called_once_with('mycli.net', 'account', 'secret')
40+
41+
42+
def test_custom_backend_is_not_unwrapped(monkeypatch: pytest.MonkeyPatch, native_write: Mock) -> None:
43+
backend = Mock(backends=[Keyring()])
44+
monkeypatch.setattr(keyring_utils.keyring, 'get_keyring', lambda: backend)
45+
46+
keyring_utils.set_keyring_password('service', 'account', 'secret')
47+
48+
backend.set_password.assert_called_once_with('service', 'account', 'secret')
49+
native_write.assert_not_called()
50+
51+
52+
def test_native_subclass_retains_custom_write(monkeypatch: pytest.MonkeyPatch, native_write: Mock) -> None:
53+
class CustomKeyring(Keyring):
54+
pass
55+
56+
backend = CustomKeyring()
57+
write = Mock()
58+
monkeypatch.setattr(backend, 'set_password', write)
59+
monkeypatch.setattr(keyring_utils.keyring, 'get_keyring', lambda: backend)
60+
61+
keyring_utils.set_keyring_password('service', 'account', 'secret')
62+
63+
write.assert_called_once_with('service', 'account', 'secret')
64+
native_write.assert_not_called()
65+
66+
67+
@pytest.mark.parametrize('read_only', [False, True])
68+
def test_chainer_preserves_backend_order(monkeypatch: pytest.MonkeyPatch, native_write: Mock, read_only: bool) -> None:
69+
first = Mock()
70+
if read_only:
71+
first.set_password.side_effect = NotImplementedError
72+
monkeypatch.setattr(ChainerBackend, 'backends', [first, Keyring()])
73+
monkeypatch.setattr(keyring_utils.keyring, 'get_keyring', ChainerBackend)
74+
75+
keyring_utils.set_keyring_password('service', 'account', 'secret')
76+
77+
first.set_password.assert_called_once_with('service', 'account', 'secret')
78+
assert native_write.call_count == int(read_only)
79+
80+
81+
@pytest.mark.parametrize('chained', [False, True])
82+
def test_native_failure_never_falls_back(monkeypatch: pytest.MonkeyPatch, native_write: Mock, chained: bool) -> None:
83+
native_write.side_effect = RuntimeError('denied')
84+
fallback = Mock()
85+
backend = Keyring()
86+
monkeypatch.setattr(backend, 'set_password', Mock(side_effect=AssertionError('Unsafe native write')))
87+
monkeypatch.setattr(ChainerBackend, 'backends', [backend, fallback])
88+
monkeypatch.setattr(keyring_utils.keyring, 'get_keyring', lambda: ChainerBackend() if chained else backend)
89+
90+
with pytest.raises(RuntimeError, match='denied'):
91+
keyring_utils.set_keyring_password('service', 'account', 'secret')
92+
93+
fallback.set_password.assert_not_called()
94+
95+
96+
def test_read_only_chainer_keeps_existing_behavior(monkeypatch: pytest.MonkeyPatch, native_write: Mock) -> None:
97+
backend = Mock()
98+
backend.set_password.side_effect = NotImplementedError
99+
monkeypatch.setattr(ChainerBackend, 'backends', [backend])
100+
monkeypatch.setattr(keyring_utils.keyring, 'get_keyring', ChainerBackend)
101+
102+
keyring_utils.set_keyring_password('service', 'account', 'secret')
103+
104+
backend.set_password.assert_called_once()
105+
native_write.assert_not_called()

0 commit comments

Comments
 (0)