kiwoom-cli 2.0.0__tar.gz → 2.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/PKG-INFO +15 -17
  2. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/README.md +14 -15
  3. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/__init__.py +1 -1
  4. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/config.py +60 -28
  5. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/main.py +33 -38
  6. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli.egg-info/PKG-INFO +15 -17
  7. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli.egg-info/SOURCES.txt +2 -2
  8. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli.egg-info/requires.txt +0 -1
  9. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/pyproject.toml +0 -1
  10. kiwoom_cli-2.1.0/tests/test_auth_cli.py +97 -0
  11. kiwoom_cli-2.1.0/tests/test_credentials.py +115 -0
  12. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/tests/test_migration.py +22 -21
  13. kiwoom_cli-2.0.0/kiwoom_cli/secure_store.py +0 -133
  14. kiwoom_cli-2.0.0/tests/test_secure_store.py +0 -137
  15. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/LICENSE +0 -0
  16. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/api_spec.py +0 -0
  17. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/auth.py +0 -0
  18. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/client.py +0 -0
  19. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/commands/__init__.py +0 -0
  20. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/commands/_constants.py +0 -0
  21. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/commands/account.py +0 -0
  22. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/commands/dashboard.py +0 -0
  23. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/commands/market.py +0 -0
  24. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/commands/order.py +0 -0
  25. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/commands/stock.py +0 -0
  26. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/commands/stream.py +0 -0
  27. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/commands/us/__init__.py +0 -0
  28. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/commands/us/_constants.py +0 -0
  29. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/commands/us/account_ops.py +0 -0
  30. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/commands/us/detect.py +0 -0
  31. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/commands/us/exchange.py +0 -0
  32. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/commands/us/order_ops.py +0 -0
  33. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/commands/us/stock_ops.py +0 -0
  34. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/commands/watch.py +0 -0
  35. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/formatters.py +0 -0
  36. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/output.py +0 -0
  37. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/streaming.py +0 -0
  38. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli.egg-info/dependency_links.txt +0 -0
  39. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli.egg-info/entry_points.txt +0 -0
  40. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli.egg-info/top_level.txt +0 -0
  41. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/setup.cfg +0 -0
  42. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/tests/test_account.py +0 -0
  43. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/tests/test_auth.py +0 -0
  44. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/tests/test_cli.py +0 -0
  45. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/tests/test_client.py +0 -0
  46. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/tests/test_formatters.py +0 -0
  47. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/tests/test_formatters_property.py +0 -0
  48. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/tests/test_market.py +0 -0
  49. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/tests/test_order.py +0 -0
  50. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/tests/test_stock.py +0 -0
  51. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/tests/test_us.py +0 -0
  52. {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/tests/test_us_account.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiwoom-cli
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: User-friendly CLI for the Kiwoom Securities REST API (키움증권)
5
5
  License: LicenseRef-kiwoom-cli-Source-Available-1.0
6
6
  Project-URL: Homepage, https://github.com/gejyn14/kiwoom-cli
@@ -26,7 +26,6 @@ Requires-Dist: tomli>=2.0; python_version < "3.11"
26
26
  Requires-Dist: tomli-w>=1.0
27
27
  Requires-Dist: keyring>=25.0
28
28
  Requires-Dist: websockets>=12.0
29
- Requires-Dist: cryptography>=42.0
30
29
  Provides-Extra: dev
31
30
  Requires-Dist: pytest>=8.0; extra == "dev"
32
31
  Requires-Dist: pytest-httpx>=0.30; extra == "dev"
@@ -55,13 +54,13 @@ pip install kiwoom-cli
55
54
  ## 시작하기
56
55
 
57
56
  ```bash
58
- # 1. 초기 설정 (appkey, secretkey 입력 + 암호화 비밀번호 설정)
57
+ # 1. 초기 설정 (appkey, secretkey 입력)
59
58
  kiwoom config setup
60
59
 
61
- # 2. 토큰 발급 (비밀번호 확인 후 발급)
60
+ # 2. 토큰 발급
62
61
  kiwoom auth login
63
62
 
64
- # 3. 사용 (토큰 발급 후에는 비밀번호 불필요)
63
+ # 3. 사용 (프롬프트 없음)
65
64
  kiwoom stock info 005930
66
65
  ```
67
66
 
@@ -87,7 +86,7 @@ export KIWOOM_DOMAIN="prod" # prod 또는 mock
87
86
  export KIWOOM_ACCOUNT="1234567" # 선택
88
87
  ```
89
88
 
90
- appkey/secretkey는 보안을 위해 환경변수를 지원하지 않습니다. 반드시 `kiwoom config setup`으로 암호화 저장하세요.
89
+ appkey/secretkey는 보안을 위해 환경변수를 지원하지 않습니다. 반드시 `kiwoom config setup`으로 OS 키체인에 저장하세요.
91
90
 
92
91
  ### 멀티 프로필
93
92
 
@@ -472,7 +471,7 @@ eval (env _KIWOOM_COMPLETE=fish_source kiwoom)
472
471
  | 항목 | 값 |
473
472
  | ---------------- | ---------------------------------------- |
474
473
  | 설정 파일 | `~/.kiwoom/config.toml` (도메인, 계좌만) |
475
- | appkey/secretkey | OS 키체인 (암호화 저장) |
474
+ | appkey/secretkey | OS 키체인 |
476
475
  | 토큰 | OS 키체인 |
477
476
  | 캐시 디렉터리 | `~/.kiwoom/cache/` |
478
477
  | 운영 도메인 | `https://api.kiwoom.com` |
@@ -487,18 +486,17 @@ eval (env _KIWOOM_COMPLETE=fish_source kiwoom)
487
486
 
488
487
  ## 보안
489
488
 
490
- 인증정보(appkey, secretkey)는 **OS 키체인에 암호화되어 저장**됩니다. 파일로 존재하지 않으며, 비밀번호 없이는 복호화할 없습니다.
489
+ 모든 인증정보(appkey, secretkey, 토큰)는 **OS 키체인**(macOS Keychain / Windows Credential Manager / Linux Secret Service)에 저장됩니다. 파일로 존재하지 않으며, 키체인이 디스크 저장 암호화를 담당합니다. `gh`, `aws`, `docker` CLI와 동일한 모델입니다.
491
490
 
492
- | 항목 | 저장 방식 | 비밀번호 필요 |
493
- | ------------------ | ------------------ | :---------------------------------: |
494
- | appkey / secretkey | 암호화 + OS 키체인 | O (`config setup`, `auth login` 시) |
495
- | 토큰 | OS 키체인 (평문) | X (만료되는 값) |
496
- | config.toml | 도메인, 계좌번호만 | X |
491
+ | 항목 | 저장 방식 | 프롬프트 |
492
+ | ------------------ | ------------------ | :------: |
493
+ | appkey / secretkey | OS 키체인 | X |
494
+ | 토큰 | OS 키체인 | X |
495
+ | config.toml | 도메인, 계좌번호만 | X |
497
496
 
498
- - `config setup` 설정한 비밀번호로 appkey/secretkey를 암호화
499
- - `auth login/logout` 비밀번호 확인 키체인 접근
500
- - 토큰 발급 일반 명령어는 비밀번호 없이 사용
501
- - `keyring.get_password()`로 직접 접근해도 암호화된 값만 반환
497
+ - 모든 명령어는 비밀번호/생체인증 프롬프트 없이 동작 (AI 에이전트·자동화 친화적)
498
+ - 자체 암호화 계층은 의도적으로 두지 않음 — 추가 계층은 명령마다 잠금 해제 프롬프트를 요구하게 되어 CLI 사용성을 해침
499
+ - v2.0 이하에서 업그레이드한 경우: 암호화 저장소 형식이 제거되어 `kiwoom config setup`을 한 번 다시 실행해야 합니다
502
500
 
503
501
  ## License
504
502
 
@@ -17,13 +17,13 @@ pip install kiwoom-cli
17
17
  ## 시작하기
18
18
 
19
19
  ```bash
20
- # 1. 초기 설정 (appkey, secretkey 입력 + 암호화 비밀번호 설정)
20
+ # 1. 초기 설정 (appkey, secretkey 입력)
21
21
  kiwoom config setup
22
22
 
23
- # 2. 토큰 발급 (비밀번호 확인 후 발급)
23
+ # 2. 토큰 발급
24
24
  kiwoom auth login
25
25
 
26
- # 3. 사용 (토큰 발급 후에는 비밀번호 불필요)
26
+ # 3. 사용 (프롬프트 없음)
27
27
  kiwoom stock info 005930
28
28
  ```
29
29
 
@@ -49,7 +49,7 @@ export KIWOOM_DOMAIN="prod" # prod 또는 mock
49
49
  export KIWOOM_ACCOUNT="1234567" # 선택
50
50
  ```
51
51
 
52
- appkey/secretkey는 보안을 위해 환경변수를 지원하지 않습니다. 반드시 `kiwoom config setup`으로 암호화 저장하세요.
52
+ appkey/secretkey는 보안을 위해 환경변수를 지원하지 않습니다. 반드시 `kiwoom config setup`으로 OS 키체인에 저장하세요.
53
53
 
54
54
  ### 멀티 프로필
55
55
 
@@ -434,7 +434,7 @@ eval (env _KIWOOM_COMPLETE=fish_source kiwoom)
434
434
  | 항목 | 값 |
435
435
  | ---------------- | ---------------------------------------- |
436
436
  | 설정 파일 | `~/.kiwoom/config.toml` (도메인, 계좌만) |
437
- | appkey/secretkey | OS 키체인 (암호화 저장) |
437
+ | appkey/secretkey | OS 키체인 |
438
438
  | 토큰 | OS 키체인 |
439
439
  | 캐시 디렉터리 | `~/.kiwoom/cache/` |
440
440
  | 운영 도메인 | `https://api.kiwoom.com` |
@@ -449,18 +449,17 @@ eval (env _KIWOOM_COMPLETE=fish_source kiwoom)
449
449
 
450
450
  ## 보안
451
451
 
452
- 인증정보(appkey, secretkey)는 **OS 키체인에 암호화되어 저장**됩니다. 파일로 존재하지 않으며, 비밀번호 없이는 복호화할 없습니다.
452
+ 모든 인증정보(appkey, secretkey, 토큰)는 **OS 키체인**(macOS Keychain / Windows Credential Manager / Linux Secret Service)에 저장됩니다. 파일로 존재하지 않으며, 키체인이 디스크 저장 암호화를 담당합니다. `gh`, `aws`, `docker` CLI와 동일한 모델입니다.
453
453
 
454
- | 항목 | 저장 방식 | 비밀번호 필요 |
455
- | ------------------ | ------------------ | :---------------------------------: |
456
- | appkey / secretkey | 암호화 + OS 키체인 | O (`config setup`, `auth login` 시) |
457
- | 토큰 | OS 키체인 (평문) | X (만료되는 값) |
458
- | config.toml | 도메인, 계좌번호만 | X |
454
+ | 항목 | 저장 방식 | 프롬프트 |
455
+ | ------------------ | ------------------ | :------: |
456
+ | appkey / secretkey | OS 키체인 | X |
457
+ | 토큰 | OS 키체인 | X |
458
+ | config.toml | 도메인, 계좌번호만 | X |
459
459
 
460
- - `config setup` 설정한 비밀번호로 appkey/secretkey를 암호화
461
- - `auth login/logout` 비밀번호 확인 키체인 접근
462
- - 토큰 발급 일반 명령어는 비밀번호 없이 사용
463
- - `keyring.get_password()`로 직접 접근해도 암호화된 값만 반환
460
+ - 모든 명령어는 비밀번호/생체인증 프롬프트 없이 동작 (AI 에이전트·자동화 친화적)
461
+ - 자체 암호화 계층은 의도적으로 두지 않음 — 추가 계층은 명령마다 잠금 해제 프롬프트를 요구하게 되어 CLI 사용성을 해침
462
+ - v2.0 이하에서 업그레이드한 경우: 암호화 저장소 형식이 제거되어 `kiwoom config setup`을 한 번 다시 실행해야 합니다
464
463
 
465
464
  ## License
466
465
 
@@ -1,3 +1,3 @@
1
1
  """Kiwoom Securities REST API CLI."""
2
2
 
3
- __version__ = "2.0.0"
3
+ __version__ = "2.1.0"
@@ -1,10 +1,11 @@
1
1
  """Configuration management for Kiwoom CLI.
2
2
 
3
- Priority: environment variables > secure store (encrypted keychain) > config.toml
3
+ Priority: environment variables > OS keychain > config.toml
4
4
 
5
- Sensitive credentials (appkey, secretkey, token) are encrypted with a
6
- password-derived key and stored in the OS keychain via SecureStore.
7
- Even direct keychain access (keyring.get_password) returns encrypted data.
5
+ Credentials (appkey, secretkey, token) are stored directly in the OS
6
+ keychain via keyring (macOS Keychain / Windows Credential Manager /
7
+ Linux Secret Service). The keychain encrypts secrets at rest; no
8
+ app-level password is required — commands never prompt.
8
9
 
9
10
  Non-sensitive settings (domain, account) remain in config.toml.
10
11
 
@@ -30,8 +31,6 @@ else:
30
31
  import keyring
31
32
  import tomli_w
32
33
 
33
- from .secure_store import SecureStore
34
-
35
34
  CONFIG_DIR = Path.home() / ".kiwoom"
36
35
  CONFIG_FILE = CONFIG_DIR / "config.toml"
37
36
  CACHE_DIR = CONFIG_DIR / "cache"
@@ -48,10 +47,6 @@ DEFAULT_CONFIG = {
48
47
  "profiles": {"default": {"domain": "mock", "account": ""}},
49
48
  }
50
49
 
51
- # Shared SecureStore instance
52
- store = SecureStore(KEYRING_SERVICE)
53
-
54
-
55
50
  def ensure_config_dir() -> None:
56
51
  CONFIG_DIR.mkdir(parents=True, exist_ok=True)
57
52
 
@@ -97,24 +92,70 @@ def get_domain(profile: str | None = None) -> str:
97
92
  return DOMAINS.get(key, DOMAINS["mock"])
98
93
 
99
94
 
95
+ def is_legacy_encrypted() -> bool:
96
+ """True if the pre-v2.1 password-encrypted format is present in the keychain.
97
+
98
+ Keyring errors (locked/absent keychain in headless or CI environments) are
99
+ treated as "not legacy" so commands like --help never crash.
100
+ """
101
+ try:
102
+ return keyring.get_password(KEYRING_SERVICE, "_salt") is not None
103
+ except Exception:
104
+ return False
105
+
106
+
107
+ def clear_legacy_sentinels() -> None:
108
+ """Remove the legacy SecureStore sentinels (_salt, _verify)."""
109
+ for key in ("_salt", "_verify"):
110
+ try:
111
+ keyring.delete_password(KEYRING_SERVICE, key)
112
+ except keyring.errors.PasswordDeleteError:
113
+ pass
114
+
115
+
116
+ def purge_legacy_credentials() -> None:
117
+ """Delete legacy Fernet-encrypted appkey/secretkey entries for all profiles.
118
+
119
+ Tokens are untouched (they were always stored plaintext and remain valid).
120
+ """
121
+ for p in get_profiles():
122
+ for key in (f"{p}:appkey", f"{p}:secretkey"):
123
+ try:
124
+ keyring.delete_password(KEYRING_SERVICE, key)
125
+ except keyring.errors.PasswordDeleteError:
126
+ pass
127
+
128
+
129
+ def is_configured(profile: str | None = None) -> bool:
130
+ """True if an appkey is stored for the profile (and not in legacy format)."""
131
+ if is_legacy_encrypted():
132
+ return False
133
+ p = resolve_profile(profile)
134
+ return keyring.get_password(KEYRING_SERVICE, f"{p}:appkey") is not None
135
+
136
+
100
137
  def get_appkey(profile: str | None = None) -> str:
138
+ if is_legacy_encrypted():
139
+ return ""
101
140
  p = resolve_profile(profile)
102
- return store.get(f"{p}:appkey") or ""
141
+ return keyring.get_password(KEYRING_SERVICE, f"{p}:appkey") or ""
103
142
 
104
143
 
105
144
  def get_secretkey(profile: str | None = None) -> str:
145
+ if is_legacy_encrypted():
146
+ return ""
106
147
  p = resolve_profile(profile)
107
- return store.get(f"{p}:secretkey") or ""
148
+ return keyring.get_password(KEYRING_SERVICE, f"{p}:secretkey") or ""
108
149
 
109
150
 
110
151
  def set_appkey(value: str, profile: str | None = None) -> None:
111
152
  p = resolve_profile(profile)
112
- store.set(f"{p}:appkey", value)
153
+ keyring.set_password(KEYRING_SERVICE, f"{p}:appkey", value)
113
154
 
114
155
 
115
156
  def set_secretkey(value: str, profile: str | None = None) -> None:
116
157
  p = resolve_profile(profile)
117
- store.set(f"{p}:secretkey", value)
158
+ keyring.set_password(KEYRING_SERVICE, f"{p}:secretkey", value)
118
159
 
119
160
 
120
161
  def get_account(profile: str | None = None) -> str:
@@ -146,31 +187,22 @@ def set_default_profile(name: str) -> None:
146
187
 
147
188
 
148
189
  def migrate_from_plaintext() -> bool:
149
- """Migrate plaintext credentials to encrypted secure store."""
190
+ """Migrate legacy plaintext credential locations into the keychain."""
150
191
  migrated = False
151
- # Migrate from config.toml
192
+ # Migrate from config.toml [auth] section
152
193
  cfg = load_config()
153
194
  auth_section = cfg.get("auth", {})
154
195
  ak = auth_section.get("appkey", "")
155
196
  sk = auth_section.get("secretkey", "")
156
197
  if ak or sk:
157
198
  if ak:
158
- store.set("default:appkey", ak)
199
+ keyring.set_password(KEYRING_SERVICE, "default:appkey", ak)
159
200
  if sk:
160
- store.set("default:secretkey", sk)
201
+ keyring.set_password(KEYRING_SERVICE, "default:secretkey", sk)
161
202
  cfg.pop("auth", None)
162
203
  save_config(cfg)
163
204
  migrated = True
164
- # Migrate from plain keyring (v0.4.0 format)
165
- plain_ak = keyring.get_password(KEYRING_SERVICE, "appkey")
166
- if plain_ak and not plain_ak.startswith("ey"): # not already encrypted (base64 JSON)
167
- store.set("default:appkey", plain_ak)
168
- migrated = True
169
- plain_sk = keyring.get_password(KEYRING_SERVICE, "secretkey")
170
- if plain_sk and not plain_sk.startswith("ey"):
171
- store.set("default:secretkey", plain_sk)
172
- migrated = True
173
- # Migrate token file to plain keyring
205
+ # Migrate token file to keyring
174
206
  token_file = CONFIG_DIR / "token"
175
207
  if token_file.exists():
176
208
  token = token_file.read_text().strip()
@@ -79,16 +79,23 @@ def cli(ctx, output_format, profile, no_color):
79
79
  ctx.obj["format"] = output_format
80
80
  ctx.obj["profile"] = profile
81
81
 
82
- # Auto-migrate plaintext credentials to encrypted store
83
- if config.store.is_initialized and config.migrate_from_plaintext():
82
+ # Auto-migrate plaintext credentials into the keychain
83
+ if config.migrate_from_plaintext():
84
84
  from .output import err_console
85
- err_console.print("[yellow]인증정보를 암호화된 키체인으로 이전했습니다.[/]")
85
+ err_console.print("[yellow]인증정보를 키체인으로 이전했습니다.[/]")
86
86
 
87
87
  # Auto-migrate pre-profile config to profile-aware format
88
- if config.store.is_initialized and config.migrate_to_profiles():
88
+ if config.migrate_to_profiles():
89
89
  from .output import err_console
90
90
  err_console.print("[yellow]프로필 형식으로 마이그레이션 완료.[/]")
91
91
 
92
+ # Legacy password-encrypted format: credentials must be re-entered
93
+ if config.is_legacy_encrypted():
94
+ from .output import err_console
95
+ err_console.print(
96
+ "[yellow]암호화 저장소 형식이 변경되었습니다. 'kiwoom config setup'으로 다시 설정하세요.[/]"
97
+ )
98
+
92
99
  if no_color:
93
100
  from rich.console import Console as RichConsole
94
101
  from . import output
@@ -112,16 +119,10 @@ def config_cmd():
112
119
  @click.option("--account", prompt="계좌번호 (없으면 Enter)", default="", help="계좌번호")
113
120
  def config_setup(profile: str, appkey: str, secretkey: str, domain: str, account: str):
114
121
  """초기 설정 (App Key, Secret Key, 도메인)."""
115
- password = click.prompt("시스템 비밀번호 (인증정보 암호화에 사용)", hide_input=True)
116
- if config.store.is_initialized:
117
- if not config.store.unlock(password):
118
- # Unlock failed — possibly old encryption format. Offer re-init.
119
- if click.confirm("비밀번호 검증 실패. 암호화 저장소를 재설정하시겠습니까?"):
120
- config.store.setup(password)
121
- else:
122
- raise SystemExit(1)
123
- else:
124
- config.store.setup(password)
122
+ if config.is_legacy_encrypted():
123
+ # Old password-encrypted entries are unusable — purge before writing new keys
124
+ config.purge_legacy_credentials()
125
+ config.clear_legacy_sentinels()
125
126
  config.set_appkey(appkey, profile=profile)
126
127
  config.set_secretkey(secretkey, profile=profile)
127
128
  cfg = config.load_config()
@@ -133,7 +134,7 @@ def config_setup(profile: str, appkey: str, secretkey: str, domain: str, account
133
134
  cfg.pop("auth", None)
134
135
  config.save_config(cfg)
135
136
  console.print(f"[green]설정 완료![/] (프로필: {profile})")
136
- console.print(" App Key/Secret Key: [bold]암호화되어 키체인에 저장됨[/]")
137
+ console.print(" App Key/Secret Key: [bold]OS 키체인에 저장됨[/]")
137
138
  console.print(f" 도메인: [bold]{config.DOMAINS[domain]}[/]")
138
139
 
139
140
 
@@ -143,14 +144,14 @@ def config_show(ctx):
143
144
  """현재 설정 확인."""
144
145
  profile = config.resolve_profile(ctx.obj.get("profile") if ctx.obj else None)
145
146
  cfg = config.load_config()
146
- initialized = config.store.is_initialized
147
+ configured = config.is_configured(profile)
147
148
  profile_cfg = cfg.get("profiles", {}).get(profile, {})
148
149
  console.print(f" 프로필: [bold]{profile}[/]")
149
150
  console.print(f" 설정 파일: {config.CONFIG_FILE}")
150
151
  console.print(f" 도메인: {profile_cfg.get('domain', 'mock')}")
151
- console.print(f" App Key: {'[dim]설정됨 (암호화)[/]' if initialized else '(미설정)'}")
152
+ console.print(f" App Key: {'[dim]설정됨 (키체인)[/]' if configured else '(미설정)'}")
152
153
  console.print(f" 계좌번호: {profile_cfg.get('account', '(미설정)')}")
153
- console.print(f" 보안: [bold]{'SecureStore 활성' if initialized else '미초기화'}[/]")
154
+ console.print(f" 보안: [bold]{'OS 키체인 저장' if configured else '미설정'}[/]")
154
155
 
155
156
 
156
157
  @config_cmd.command("set")
@@ -217,22 +218,13 @@ def auth_cmd():
217
218
  pass
218
219
 
219
220
 
220
- def _unlock_store() -> bool:
221
- """Prompt for password and unlock the secure store."""
222
- if not config.store.is_initialized:
223
- console.print("[red]설정이 필요합니다. 먼저 실행: kiwoom config setup[/]")
224
- return False
225
- password = click.prompt("비밀번호", hide_input=True)
226
- if not config.store.unlock(password):
227
- console.print("[red]비밀번호가 일치하지 않습니다.[/]")
228
- return False
229
- return True
230
-
231
-
232
221
  @auth_cmd.command("login")
233
- def auth_login():
234
- """접근토큰 발급 (비밀번호 확인 후 암호화된 인증정보 사용)."""
235
- if not _unlock_store():
222
+ @click.pass_context
223
+ def auth_login(ctx):
224
+ """접근토큰 발급."""
225
+ profile = config.resolve_profile(ctx.obj.get("profile") if ctx.obj else None)
226
+ if not config.is_configured(profile):
227
+ console.print("[red]설정이 필요합니다. 먼저 실행: kiwoom config setup[/]")
236
228
  raise SystemExit(1)
237
229
  with KiwoomClient() as c:
238
230
  try:
@@ -246,9 +238,12 @@ def auth_login():
246
238
 
247
239
 
248
240
  @auth_cmd.command("logout")
249
- def auth_logout():
250
- """접근토큰 폐기 (비밀번호 확인 필요)."""
251
- if not _unlock_store():
241
+ @click.pass_context
242
+ def auth_logout(ctx):
243
+ """접근토큰 폐기."""
244
+ profile = config.resolve_profile(ctx.obj.get("profile") if ctx.obj else None)
245
+ if not config.is_configured(profile):
246
+ console.print("[red]설정이 필요합니다. 먼저 실행: kiwoom config setup[/]")
252
247
  raise SystemExit(1)
253
248
  with KiwoomClient() as c:
254
249
  try:
@@ -262,10 +257,10 @@ def auth_logout():
262
257
  @click.pass_context
263
258
  def auth_status(ctx):
264
259
  """토큰 상태 확인."""
265
- if not config.store.is_initialized:
260
+ profile = config.resolve_profile(ctx.obj.get("profile") if ctx.obj else None)
261
+ if not config.is_configured(profile):
266
262
  console.print("[yellow]설정 필요.[/] 'kiwoom config setup' 으로 설정하세요.")
267
263
  return
268
- profile = config.resolve_profile(ctx.obj.get("profile") if ctx.obj else None)
269
264
  import keyring as _kr
270
265
  has_token = _kr.get_password(config.KEYRING_SERVICE, f"{profile}:token") is not None
271
266
  console.print(f" 프로필: [bold]{profile}[/]")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiwoom-cli
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: User-friendly CLI for the Kiwoom Securities REST API (키움증권)
5
5
  License: LicenseRef-kiwoom-cli-Source-Available-1.0
6
6
  Project-URL: Homepage, https://github.com/gejyn14/kiwoom-cli
@@ -26,7 +26,6 @@ Requires-Dist: tomli>=2.0; python_version < "3.11"
26
26
  Requires-Dist: tomli-w>=1.0
27
27
  Requires-Dist: keyring>=25.0
28
28
  Requires-Dist: websockets>=12.0
29
- Requires-Dist: cryptography>=42.0
30
29
  Provides-Extra: dev
31
30
  Requires-Dist: pytest>=8.0; extra == "dev"
32
31
  Requires-Dist: pytest-httpx>=0.30; extra == "dev"
@@ -55,13 +54,13 @@ pip install kiwoom-cli
55
54
  ## 시작하기
56
55
 
57
56
  ```bash
58
- # 1. 초기 설정 (appkey, secretkey 입력 + 암호화 비밀번호 설정)
57
+ # 1. 초기 설정 (appkey, secretkey 입력)
59
58
  kiwoom config setup
60
59
 
61
- # 2. 토큰 발급 (비밀번호 확인 후 발급)
60
+ # 2. 토큰 발급
62
61
  kiwoom auth login
63
62
 
64
- # 3. 사용 (토큰 발급 후에는 비밀번호 불필요)
63
+ # 3. 사용 (프롬프트 없음)
65
64
  kiwoom stock info 005930
66
65
  ```
67
66
 
@@ -87,7 +86,7 @@ export KIWOOM_DOMAIN="prod" # prod 또는 mock
87
86
  export KIWOOM_ACCOUNT="1234567" # 선택
88
87
  ```
89
88
 
90
- appkey/secretkey는 보안을 위해 환경변수를 지원하지 않습니다. 반드시 `kiwoom config setup`으로 암호화 저장하세요.
89
+ appkey/secretkey는 보안을 위해 환경변수를 지원하지 않습니다. 반드시 `kiwoom config setup`으로 OS 키체인에 저장하세요.
91
90
 
92
91
  ### 멀티 프로필
93
92
 
@@ -472,7 +471,7 @@ eval (env _KIWOOM_COMPLETE=fish_source kiwoom)
472
471
  | 항목 | 값 |
473
472
  | ---------------- | ---------------------------------------- |
474
473
  | 설정 파일 | `~/.kiwoom/config.toml` (도메인, 계좌만) |
475
- | appkey/secretkey | OS 키체인 (암호화 저장) |
474
+ | appkey/secretkey | OS 키체인 |
476
475
  | 토큰 | OS 키체인 |
477
476
  | 캐시 디렉터리 | `~/.kiwoom/cache/` |
478
477
  | 운영 도메인 | `https://api.kiwoom.com` |
@@ -487,18 +486,17 @@ eval (env _KIWOOM_COMPLETE=fish_source kiwoom)
487
486
 
488
487
  ## 보안
489
488
 
490
- 인증정보(appkey, secretkey)는 **OS 키체인에 암호화되어 저장**됩니다. 파일로 존재하지 않으며, 비밀번호 없이는 복호화할 없습니다.
489
+ 모든 인증정보(appkey, secretkey, 토큰)는 **OS 키체인**(macOS Keychain / Windows Credential Manager / Linux Secret Service)에 저장됩니다. 파일로 존재하지 않으며, 키체인이 디스크 저장 암호화를 담당합니다. `gh`, `aws`, `docker` CLI와 동일한 모델입니다.
491
490
 
492
- | 항목 | 저장 방식 | 비밀번호 필요 |
493
- | ------------------ | ------------------ | :---------------------------------: |
494
- | appkey / secretkey | 암호화 + OS 키체인 | O (`config setup`, `auth login` 시) |
495
- | 토큰 | OS 키체인 (평문) | X (만료되는 값) |
496
- | config.toml | 도메인, 계좌번호만 | X |
491
+ | 항목 | 저장 방식 | 프롬프트 |
492
+ | ------------------ | ------------------ | :------: |
493
+ | appkey / secretkey | OS 키체인 | X |
494
+ | 토큰 | OS 키체인 | X |
495
+ | config.toml | 도메인, 계좌번호만 | X |
497
496
 
498
- - `config setup` 설정한 비밀번호로 appkey/secretkey를 암호화
499
- - `auth login/logout` 비밀번호 확인 키체인 접근
500
- - 토큰 발급 일반 명령어는 비밀번호 없이 사용
501
- - `keyring.get_password()`로 직접 접근해도 암호화된 값만 반환
497
+ - 모든 명령어는 비밀번호/생체인증 프롬프트 없이 동작 (AI 에이전트·자동화 친화적)
498
+ - 자체 암호화 계층은 의도적으로 두지 않음 — 추가 계층은 명령마다 잠금 해제 프롬프트를 요구하게 되어 CLI 사용성을 해침
499
+ - v2.0 이하에서 업그레이드한 경우: 암호화 저장소 형식이 제거되어 `kiwoom config setup`을 한 번 다시 실행해야 합니다
502
500
 
503
501
  ## License
504
502
 
@@ -9,7 +9,6 @@ kiwoom_cli/config.py
9
9
  kiwoom_cli/formatters.py
10
10
  kiwoom_cli/main.py
11
11
  kiwoom_cli/output.py
12
- kiwoom_cli/secure_store.py
13
12
  kiwoom_cli/streaming.py
14
13
  kiwoom_cli.egg-info/PKG-INFO
15
14
  kiwoom_cli.egg-info/SOURCES.txt
@@ -35,14 +34,15 @@ kiwoom_cli/commands/us/order_ops.py
35
34
  kiwoom_cli/commands/us/stock_ops.py
36
35
  tests/test_account.py
37
36
  tests/test_auth.py
37
+ tests/test_auth_cli.py
38
38
  tests/test_cli.py
39
39
  tests/test_client.py
40
+ tests/test_credentials.py
40
41
  tests/test_formatters.py
41
42
  tests/test_formatters_property.py
42
43
  tests/test_market.py
43
44
  tests/test_migration.py
44
45
  tests/test_order.py
45
- tests/test_secure_store.py
46
46
  tests/test_stock.py
47
47
  tests/test_us.py
48
48
  tests/test_us_account.py
@@ -4,7 +4,6 @@ rich>=13.0
4
4
  tomli-w>=1.0
5
5
  keyring>=25.0
6
6
  websockets>=12.0
7
- cryptography>=42.0
8
7
 
9
8
  [:python_version < "3.11"]
10
9
  tomli>=2.0
@@ -29,7 +29,6 @@ dependencies = [
29
29
  "tomli-w>=1.0",
30
30
  "keyring>=25.0",
31
31
  "websockets>=12.0",
32
- "cryptography>=42.0",
33
32
  ]
34
33
 
35
34
  [project.optional-dependencies]
@@ -0,0 +1,97 @@
1
+ """CLI-level tests: no password prompts anywhere after keychain-only change."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import keyring
6
+ import pytest
7
+ from click.testing import CliRunner
8
+
9
+ from kiwoom_cli import config
10
+ from kiwoom_cli.main import cli
11
+
12
+
13
+ @pytest.fixture(autouse=True)
14
+ def mem_keyring(monkeypatch):
15
+ data: dict[str, str] = {}
16
+ monkeypatch.setattr(keyring, "get_password", lambda svc, key: data.get(f"{svc}:{key}"))
17
+ monkeypatch.setattr(keyring, "set_password", lambda svc, key, val: data.__setitem__(f"{svc}:{key}", val))
18
+
19
+ def _delete(svc, key):
20
+ if f"{svc}:{key}" not in data:
21
+ raise keyring.errors.PasswordDeleteError(key)
22
+ del data[f"{svc}:{key}"]
23
+
24
+ monkeypatch.setattr(keyring, "delete_password", _delete)
25
+ return data
26
+
27
+
28
+ @pytest.fixture(autouse=True)
29
+ def isolated_config(tmp_path, monkeypatch):
30
+ monkeypatch.setattr(config, "CONFIG_DIR", tmp_path)
31
+ monkeypatch.setattr(config, "CONFIG_FILE", tmp_path / "config.toml")
32
+ return tmp_path
33
+
34
+
35
+ def test_config_setup_no_password_prompt(mem_keyring):
36
+ """setup succeeds with only appkey/secretkey/domain/account inputs."""
37
+ runner = CliRunner()
38
+ result = runner.invoke(
39
+ cli,
40
+ ["config", "setup"],
41
+ input="my-appkey\nmy-secretkey\nmock\n\n",
42
+ )
43
+ assert result.exit_code == 0, result.output
44
+ assert "비밀번호" not in result.output
45
+ assert mem_keyring[f"{config.KEYRING_SERVICE}:default:appkey"] == "my-appkey"
46
+ assert mem_keyring[f"{config.KEYRING_SERVICE}:default:secretkey"] == "my-secretkey"
47
+
48
+
49
+ def test_config_setup_purges_legacy_format(mem_keyring):
50
+ """setup clears _salt/_verify and old ciphertext entries."""
51
+ keyring.set_password(config.KEYRING_SERVICE, "_salt", "oldsalt")
52
+ keyring.set_password(config.KEYRING_SERVICE, "_verify", "oldverify")
53
+ keyring.set_password(config.KEYRING_SERVICE, "default:appkey", "gAAAA-cipher")
54
+
55
+ runner = CliRunner()
56
+ result = runner.invoke(
57
+ cli,
58
+ ["config", "setup"],
59
+ input="new-appkey\nnew-secretkey\nmock\n\n",
60
+ )
61
+ assert result.exit_code == 0, result.output
62
+ assert keyring.get_password(config.KEYRING_SERVICE, "_salt") is None
63
+ assert keyring.get_password(config.KEYRING_SERVICE, "_verify") is None
64
+ assert config.get_appkey(profile="default") == "new-appkey"
65
+
66
+
67
+ def test_auth_login_no_password_prompt(monkeypatch, mem_keyring):
68
+ """auth login issues a token without any interactive prompt."""
69
+ from kiwoom_cli import client as client_mod
70
+
71
+ keyring.set_password(config.KEYRING_SERVICE, "default:appkey", "ak")
72
+ keyring.set_password(config.KEYRING_SERVICE, "default:secretkey", "sk")
73
+
74
+ monkeypatch.setattr(
75
+ client_mod.KiwoomClient, "issue_token", lambda self: "issued-token-value-12345"
76
+ )
77
+
78
+ runner = CliRunner()
79
+ result = runner.invoke(cli, ["auth", "login"], input="")
80
+ assert result.exit_code == 0, result.output
81
+ assert "비밀번호" not in result.output
82
+ assert "토큰 발급 완료" in result.output
83
+
84
+
85
+ def test_auth_login_unconfigured_tells_user_to_setup(mem_keyring):
86
+ runner = CliRunner()
87
+ result = runner.invoke(cli, ["auth", "login"])
88
+ assert result.exit_code != 0
89
+ assert "config setup" in result.output
90
+
91
+
92
+ def test_legacy_format_shows_resetup_notice(mem_keyring):
93
+ """Any command under legacy format prints the re-setup notice."""
94
+ keyring.set_password(config.KEYRING_SERVICE, "_salt", "oldsalt")
95
+ runner = CliRunner()
96
+ result = runner.invoke(cli, ["auth", "status"])
97
+ assert "kiwoom config setup" in result.output or "config setup" in result.output
@@ -0,0 +1,115 @@
1
+ """Tests for keyring-direct credential storage and legacy-format detection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import keyring
6
+ import pytest
7
+
8
+ from kiwoom_cli import config
9
+
10
+
11
+ @pytest.fixture(autouse=True)
12
+ def mem_keyring(monkeypatch):
13
+ """In-memory keyring backend, isolated per test."""
14
+ data: dict[str, str] = {}
15
+ monkeypatch.setattr(keyring, "get_password", lambda svc, key: data.get(f"{svc}:{key}"))
16
+ monkeypatch.setattr(keyring, "set_password", lambda svc, key, val: data.__setitem__(f"{svc}:{key}", val))
17
+
18
+ def _delete(svc, key):
19
+ if f"{svc}:{key}" not in data:
20
+ raise keyring.errors.PasswordDeleteError(key)
21
+ del data[f"{svc}:{key}"]
22
+
23
+ monkeypatch.setattr(keyring, "delete_password", _delete)
24
+ return data
25
+
26
+
27
+ @pytest.fixture
28
+ def isolated_config(tmp_path, monkeypatch):
29
+ monkeypatch.setattr(config, "CONFIG_DIR", tmp_path)
30
+ monkeypatch.setattr(config, "CONFIG_FILE", tmp_path / "config.toml")
31
+ return tmp_path
32
+
33
+
34
+ def test_appkey_roundtrip_plain(isolated_config):
35
+ """set_appkey stores the exact input value; get_appkey returns it."""
36
+ config.set_appkey("my-app-key", profile="default")
37
+ assert keyring.get_password(config.KEYRING_SERVICE, "default:appkey") == "my-app-key"
38
+ assert config.get_appkey(profile="default") == "my-app-key"
39
+
40
+
41
+ def test_secretkey_roundtrip_plain(isolated_config):
42
+ config.set_secretkey("my-secret", profile="default")
43
+ assert keyring.get_password(config.KEYRING_SERVICE, "default:secretkey") == "my-secret"
44
+ assert config.get_secretkey(profile="default") == "my-secret"
45
+
46
+
47
+ def test_get_appkey_unset_returns_empty(isolated_config):
48
+ assert config.get_appkey(profile="default") == ""
49
+
50
+
51
+ def test_is_legacy_encrypted_detects_salt(isolated_config):
52
+ assert config.is_legacy_encrypted() is False
53
+ keyring.set_password(config.KEYRING_SERVICE, "_salt", "abc123")
54
+ assert config.is_legacy_encrypted() is True
55
+
56
+
57
+ def test_is_legacy_encrypted_false_when_keyring_unavailable(isolated_config, monkeypatch):
58
+ """Locked/absent keychain (headless, CI) must not crash — treat as not legacy."""
59
+
60
+ def _raise(svc, key):
61
+ raise RuntimeError("errSecInteractionNotAllowed")
62
+
63
+ monkeypatch.setattr(keyring, "get_password", _raise)
64
+ assert config.is_legacy_encrypted() is False
65
+
66
+
67
+ def test_get_appkey_returns_empty_under_legacy_format(isolated_config):
68
+ """Fernet ciphertext must never be mistaken for a real key."""
69
+ keyring.set_password(config.KEYRING_SERVICE, "_salt", "abc123")
70
+ keyring.set_password(config.KEYRING_SERVICE, "default:appkey", "gAAAA-ciphertext")
71
+ assert config.get_appkey(profile="default") == ""
72
+ assert config.get_secretkey(profile="default") == ""
73
+
74
+
75
+ def test_is_configured(isolated_config):
76
+ assert config.is_configured("default") is False
77
+ config.set_appkey("k", profile="default")
78
+ assert config.is_configured("default") is True
79
+
80
+
81
+ def test_is_configured_false_under_legacy_format(isolated_config):
82
+ keyring.set_password(config.KEYRING_SERVICE, "default:appkey", "gAAAA-ciphertext")
83
+ keyring.set_password(config.KEYRING_SERVICE, "_salt", "abc123")
84
+ assert config.is_configured("default") is False
85
+
86
+
87
+ def test_clear_legacy_sentinels(isolated_config):
88
+ keyring.set_password(config.KEYRING_SERVICE, "_salt", "s")
89
+ keyring.set_password(config.KEYRING_SERVICE, "_verify", "v")
90
+ config.clear_legacy_sentinels()
91
+ assert keyring.get_password(config.KEYRING_SERVICE, "_salt") is None
92
+ assert keyring.get_password(config.KEYRING_SERVICE, "_verify") is None
93
+ # Idempotent — no raise when already gone
94
+ config.clear_legacy_sentinels()
95
+
96
+
97
+ def test_purge_legacy_credentials_deletes_all_profile_keys(isolated_config):
98
+ (isolated_config / "config.toml").write_text(
99
+ '[general]\ndefault_profile = "default"\n'
100
+ '[profiles.default]\ndomain = "mock"\n'
101
+ '[profiles.second]\ndomain = "prod"\n',
102
+ encoding="utf-8",
103
+ )
104
+ keyring.set_password(config.KEYRING_SERVICE, "default:appkey", "cipher1")
105
+ keyring.set_password(config.KEYRING_SERVICE, "default:secretkey", "cipher2")
106
+ keyring.set_password(config.KEYRING_SERVICE, "second:appkey", "cipher3")
107
+ keyring.set_password(config.KEYRING_SERVICE, "default:token", "tok")
108
+
109
+ config.purge_legacy_credentials()
110
+
111
+ assert keyring.get_password(config.KEYRING_SERVICE, "default:appkey") is None
112
+ assert keyring.get_password(config.KEYRING_SERVICE, "default:secretkey") is None
113
+ assert keyring.get_password(config.KEYRING_SERVICE, "second:appkey") is None
114
+ # Tokens were always plaintext — untouched
115
+ assert keyring.get_password(config.KEYRING_SERVICE, "default:token") == "tok"
@@ -1,8 +1,8 @@
1
1
  """Tests for config migration functions.
2
2
 
3
3
  Covers migrate_from_plaintext() and migrate_to_profiles() in kiwoom_cli/config.py.
4
- These functions touch module globals (CONFIG_DIR, CONFIG_FILE, store) and the
5
- keyring backend, so we use monkeypatch to isolate file I/O.
4
+ These functions touch module globals (CONFIG_DIR, CONFIG_FILE) and the keyring
5
+ backend, so we use monkeypatch to isolate file I/O.
6
6
  """
7
7
 
8
8
  from __future__ import annotations
@@ -13,14 +13,26 @@ import pytest
13
13
  from kiwoom_cli import config
14
14
 
15
15
 
16
+ @pytest.fixture(autouse=True)
17
+ def mem_keyring(monkeypatch):
18
+ data: dict[str, str] = {}
19
+ monkeypatch.setattr(keyring, "get_password", lambda svc, key: data.get(f"{svc}:{key}"))
20
+ monkeypatch.setattr(keyring, "set_password", lambda svc, key, val: data.__setitem__(f"{svc}:{key}", val))
21
+
22
+ def _delete(svc, key):
23
+ if f"{svc}:{key}" not in data:
24
+ raise keyring.errors.PasswordDeleteError(key)
25
+ del data[f"{svc}:{key}"]
26
+
27
+ monkeypatch.setattr(keyring, "delete_password", _delete)
28
+ return data
29
+
30
+
16
31
  @pytest.fixture
17
32
  def isolated_config(tmp_path, monkeypatch):
18
- """Redirect config.CONFIG_DIR/CONFIG_FILE to tmp_path and init store."""
33
+ """Redirect config.CONFIG_DIR/CONFIG_FILE to tmp_path."""
19
34
  monkeypatch.setattr(config, "CONFIG_DIR", tmp_path)
20
35
  monkeypatch.setattr(config, "CONFIG_FILE", tmp_path / "config.toml")
21
- # Reset store state and initialize with a fresh salt
22
- config.store._key = None
23
- config.store.setup("testpw")
24
36
  return tmp_path
25
37
 
26
38
 
@@ -33,8 +45,8 @@ def _write_toml(path, content: str) -> None:
33
45
  # ============================================================
34
46
 
35
47
 
36
- def test_migrate_from_plaintext_moves_toml_auth_to_store(isolated_config):
37
- """config.toml [auth] section is encrypted into store and removed from TOML."""
48
+ def test_migrate_from_plaintext_moves_toml_auth_to_keyring(isolated_config):
49
+ """config.toml [auth] section moves into keyring and is removed from TOML."""
38
50
  _write_toml(
39
51
  isolated_config / "config.toml",
40
52
  '[auth]\nappkey = "plain-key"\nsecretkey = "plain-secret"\n',
@@ -43,22 +55,12 @@ def test_migrate_from_plaintext_moves_toml_auth_to_store(isolated_config):
43
55
  result = config.migrate_from_plaintext()
44
56
 
45
57
  assert result is True
46
- assert config.store.get("default:appkey") == "plain-key"
47
- assert config.store.get("default:secretkey") == "plain-secret"
58
+ assert keyring.get_password(config.KEYRING_SERVICE, "default:appkey") == "plain-key"
59
+ assert keyring.get_password(config.KEYRING_SERVICE, "default:secretkey") == "plain-secret"
48
60
  cfg = config.load_config()
49
61
  assert "auth" not in cfg
50
62
 
51
63
 
52
- def test_migrate_from_plaintext_moves_plain_keyring_keys(isolated_config):
53
- """Plain (unencrypted) keyring entries are re-stored encrypted."""
54
- keyring.set_password(config.KEYRING_SERVICE, "appkey", "plain-xyz")
55
-
56
- result = config.migrate_from_plaintext()
57
-
58
- assert result is True
59
- assert config.store.get("default:appkey") == "plain-xyz"
60
-
61
-
62
64
  def test_migrate_from_plaintext_moves_token_file(isolated_config):
63
65
  """~/.kiwoom/token file is moved into keyring and deleted."""
64
66
  token_file = isolated_config / "token"
@@ -124,7 +126,6 @@ def test_migrate_to_profiles_skips_if_profiles_exist(isolated_config):
124
126
 
125
127
  def test_migrate_to_profiles_renames_bare_keyring_keys(isolated_config):
126
128
  """Bare keyring keys (appkey/secretkey/token) renamed to default:-prefixed."""
127
- # Write pre-profile config.toml (without [profiles] section) so migration runs
128
129
  _write_toml(
129
130
  isolated_config / "config.toml",
130
131
  '[general]\ndomain = "mock"\n',
@@ -1,133 +0,0 @@
1
- """Secure credential store with system password verification.
2
-
3
- Credentials are encrypted with a key derived from the system password,
4
- then stored in the OS keychain. Even if keychain is accessed directly
5
- (e.g. via keyring.get_password), the values are encrypted and useless
6
- without the system password.
7
-
8
- Usage:
9
- store = SecureStore("my-app")
10
- store.setup("password123") # Initialize with system password
11
- store.set("appkey", "secret-value") # Encrypt + store in keychain
12
- store.unlock("password123") # Unlock for this session
13
- value = store.get("appkey") # Decrypt + return
14
- """
15
-
16
- from __future__ import annotations
17
-
18
- import base64
19
- import hashlib
20
- import secrets
21
-
22
- import keyring
23
- from cryptography.fernet import Fernet, InvalidToken
24
-
25
-
26
- class SecureStoreError(Exception):
27
- pass
28
-
29
-
30
- class SecureStoreLocked(SecureStoreError):
31
- pass
32
-
33
-
34
- class SecureStore:
35
- """Encrypted credential store backed by OS keychain.
36
-
37
- Values are encrypted with Fernet (AES-128-CBC + HMAC) keyed by a
38
- password-derived key. The encryption key never touches disk — it exists
39
- only in memory after unlock.
40
- """
41
-
42
- def __init__(self, service: str):
43
- self.service = service
44
- self._key: bytes | None = None
45
-
46
- @property
47
- def is_initialized(self) -> bool:
48
- """Check if store has been set up (salt exists)."""
49
- return keyring.get_password(self.service, "_salt") is not None
50
-
51
- @property
52
- def is_unlocked(self) -> bool:
53
- return self._key is not None
54
-
55
- def setup(self, password: str) -> None:
56
- """Initialize the store with a password. Generates a new salt."""
57
- salt = secrets.token_hex(32)
58
- keyring.set_password(self.service, "_salt", salt)
59
- self._key = self._derive_key(password, salt)
60
- # Store a verification token to check password correctness later
61
- verify = self._encrypt(b"kiwoom-cli-verify")
62
- keyring.set_password(self.service, "_verify", verify)
63
-
64
- def unlock(self, password: str) -> bool:
65
- """Unlock the store for this session. Returns True if password is correct."""
66
- salt = keyring.get_password(self.service, "_salt")
67
- if not salt:
68
- raise SecureStoreError("Store not initialized. Run: kiwoom config setup")
69
- self._key = self._derive_key(password, salt)
70
- # Verify password correctness
71
- verify = keyring.get_password(self.service, "_verify")
72
- if not verify:
73
- return False
74
- try:
75
- decrypted = self._decrypt(verify)
76
- if decrypted != b"kiwoom-cli-verify":
77
- self._key = None
78
- return False
79
- return True
80
- except (InvalidToken, Exception):
81
- self._key = None
82
- return False
83
-
84
- def lock(self) -> None:
85
- """Lock the store, clearing the encryption key from memory."""
86
- self._key = None
87
-
88
- def set(self, name: str, value: str) -> None:
89
- """Encrypt and store a credential."""
90
- if not self._key:
91
- raise SecureStoreLocked("Store is locked. Call unlock() first.")
92
- encrypted = self._encrypt(value.encode("utf-8"))
93
- keyring.set_password(self.service, name, encrypted)
94
-
95
- def get(self, name: str) -> str | None:
96
- """Retrieve and decrypt a credential."""
97
- if not self._key:
98
- raise SecureStoreLocked("Store is locked. Call unlock() first.")
99
- encrypted = keyring.get_password(self.service, name)
100
- if encrypted is None:
101
- return None
102
- try:
103
- return self._decrypt(encrypted).decode("utf-8")
104
- except Exception:
105
- return None
106
-
107
- def delete(self, name: str) -> None:
108
- """Delete a credential."""
109
- try:
110
- keyring.delete_password(self.service, name)
111
- except keyring.errors.PasswordDeleteError:
112
- pass
113
-
114
- def _derive_key(self, password: str, salt: str) -> bytes:
115
- """Derive encryption key from password + salt using PBKDF2."""
116
- raw = hashlib.pbkdf2_hmac(
117
- "sha256",
118
- password.encode("utf-8"),
119
- salt.encode("utf-8"),
120
- iterations=100_000,
121
- dklen=32,
122
- )
123
- return base64.urlsafe_b64encode(raw)
124
-
125
- def _encrypt(self, data: bytes) -> str:
126
- """Encrypt data with the derived key."""
127
- assert self._key is not None
128
- return Fernet(self._key).encrypt(data).decode("ascii")
129
-
130
- def _decrypt(self, token: str) -> bytes:
131
- """Decrypt data with the derived key."""
132
- assert self._key is not None
133
- return Fernet(self._key).decrypt(token.encode("ascii"))
@@ -1,137 +0,0 @@
1
- """Tests for SecureStore encrypted credential storage."""
2
- from kiwoom_cli.secure_store import SecureStore, SecureStoreError, SecureStoreLocked
3
- import pytest
4
-
5
-
6
- @pytest.fixture
7
- def store_data():
8
- """Shared in-memory dict used by the store fixture."""
9
- return {}
10
-
11
-
12
- @pytest.fixture
13
- def store(monkeypatch, store_data):
14
- """Create a SecureStore with an in-memory keyring backend."""
15
- import keyring
16
- store = SecureStore("test-service")
17
- monkeypatch.setattr(keyring, "get_password", lambda svc, key: store_data.get(f"{svc}:{key}"))
18
- monkeypatch.setattr(keyring, "set_password", lambda svc, key, val: store_data.__setitem__(f"{svc}:{key}", val))
19
- monkeypatch.setattr(keyring, "delete_password", lambda svc, key: store_data.pop(f"{svc}:{key}", None))
20
- return store
21
-
22
- def test_setup_and_unlock(store):
23
- store.setup("mypassword")
24
- assert store.is_initialized
25
- store.lock()
26
- assert not store.is_unlocked
27
- assert store.unlock("mypassword")
28
- assert store.is_unlocked
29
-
30
- def test_wrong_password(store):
31
- store.setup("correct")
32
- store.lock()
33
- assert not store.unlock("wrong")
34
-
35
- def test_set_get_roundtrip(store):
36
- store.setup("pass123")
37
- store.set("appkey", "secret-value-123")
38
- store.set("secretkey", "another-secret")
39
- assert store.get("appkey") == "secret-value-123"
40
- assert store.get("secretkey") == "another-secret"
41
-
42
- def test_locked_access_raises(store):
43
- store.setup("pass")
44
- store.set("key", "val")
45
- store.lock()
46
- with pytest.raises(SecureStoreLocked):
47
- store.get("key")
48
- with pytest.raises(SecureStoreLocked):
49
- store.set("key", "val2")
50
-
51
- def test_get_nonexistent_returns_none(store):
52
- store.setup("pass")
53
- assert store.get("nonexistent") is None
54
-
55
- def test_delete(store):
56
- store.setup("pass")
57
- store.set("key", "val")
58
- store.delete("key")
59
- assert store.get("key") is None
60
-
61
-
62
- # ============================================================
63
- # Additional strict coverage
64
- # ============================================================
65
-
66
-
67
- def test_roundtrip_utf8_value(store):
68
- """UTF-8 Korean text and emoji values survive encryption roundtrip."""
69
- store.setup("pass")
70
- value = "비밀번호🔒테스트"
71
- store.set("utf8_key", value)
72
- assert store.get("utf8_key") == value
73
-
74
-
75
- def test_roundtrip_large_value(store):
76
- """8 KB value survives roundtrip."""
77
- store.setup("pass")
78
- value = "A" * 8192
79
- store.set("large_key", value)
80
- assert store.get("large_key") == value
81
-
82
-
83
- def test_delete_nonexistent_is_noop(store):
84
- """Deleting a nonexistent key does not raise."""
85
- store.setup("pass")
86
- store.delete("never_set") # should not raise
87
-
88
-
89
- def test_persistence_across_instances(monkeypatch, store_data):
90
- """Data stored in one instance is decryptable by a fresh instance with same password."""
91
- import keyring
92
- monkeypatch.setattr(keyring, "get_password", lambda svc, key: store_data.get(f"{svc}:{key}"))
93
- monkeypatch.setattr(keyring, "set_password", lambda svc, key, val: store_data.__setitem__(f"{svc}:{key}", val))
94
- monkeypatch.setattr(keyring, "delete_password", lambda svc, key: store_data.pop(f"{svc}:{key}", None))
95
-
96
- store_a = SecureStore("persist-test")
97
- store_a.setup("shared-pw")
98
- store_a.set("shared_key", "shared-value")
99
-
100
- store_b = SecureStore("persist-test")
101
- assert store_b.unlock("shared-pw") is True
102
- assert store_b.get("shared_key") == "shared-value"
103
-
104
-
105
- def test_resetup_changes_encryption_key(store):
106
- """Calling setup() twice with different passwords changes the salt."""
107
- store.setup("password-one")
108
- store.set("k", "v1")
109
- original_value = store.get("k")
110
-
111
- # Re-setup with different password generates new salt
112
- store.setup("password-two")
113
- # Old ciphertext can no longer be decrypted (new key, different salt)
114
- # _verify will work because it was re-encrypted, but old "k" won't
115
- assert store.get("k") is None or store.get("k") != original_value
116
-
117
-
118
- def test_unlock_before_setup_raises(store):
119
- """Calling unlock() on un-initialized store raises SecureStoreError."""
120
- with pytest.raises(SecureStoreError):
121
- store.unlock("anything")
122
-
123
-
124
- def test_delete_swallows_keyring_error(monkeypatch, store_data):
125
- """PasswordDeleteError from keyring.delete_password is silently caught."""
126
- import keyring
127
- import keyring.errors
128
-
129
- def failing_delete(svc, key):
130
- raise keyring.errors.PasswordDeleteError("simulated")
131
-
132
- monkeypatch.setattr(keyring, "get_password", lambda svc, key: store_data.get(f"{svc}:{key}"))
133
- monkeypatch.setattr(keyring, "set_password", lambda svc, key, val: store_data.__setitem__(f"{svc}:{key}", val))
134
- monkeypatch.setattr(keyring, "delete_password", failing_delete)
135
-
136
- store = SecureStore("test-service")
137
- store.delete("anything") # should not raise
File without changes
File without changes
File without changes
File without changes