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.
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/PKG-INFO +15 -17
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/README.md +14 -15
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/__init__.py +1 -1
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/config.py +60 -28
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/main.py +33 -38
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli.egg-info/PKG-INFO +15 -17
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli.egg-info/SOURCES.txt +2 -2
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli.egg-info/requires.txt +0 -1
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/pyproject.toml +0 -1
- kiwoom_cli-2.1.0/tests/test_auth_cli.py +97 -0
- kiwoom_cli-2.1.0/tests/test_credentials.py +115 -0
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/tests/test_migration.py +22 -21
- kiwoom_cli-2.0.0/kiwoom_cli/secure_store.py +0 -133
- kiwoom_cli-2.0.0/tests/test_secure_store.py +0 -137
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/LICENSE +0 -0
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/api_spec.py +0 -0
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/auth.py +0 -0
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/client.py +0 -0
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/commands/__init__.py +0 -0
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/commands/_constants.py +0 -0
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/commands/account.py +0 -0
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/commands/dashboard.py +0 -0
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/commands/market.py +0 -0
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/commands/order.py +0 -0
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/commands/stock.py +0 -0
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/commands/stream.py +0 -0
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/commands/us/__init__.py +0 -0
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/commands/us/_constants.py +0 -0
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/commands/us/account_ops.py +0 -0
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/commands/us/detect.py +0 -0
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/commands/us/exchange.py +0 -0
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/commands/us/order_ops.py +0 -0
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/commands/us/stock_ops.py +0 -0
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/commands/watch.py +0 -0
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/formatters.py +0 -0
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/output.py +0 -0
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli/streaming.py +0 -0
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli.egg-info/dependency_links.txt +0 -0
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli.egg-info/entry_points.txt +0 -0
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/kiwoom_cli.egg-info/top_level.txt +0 -0
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/setup.cfg +0 -0
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/tests/test_account.py +0 -0
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/tests/test_auth.py +0 -0
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/tests/test_cli.py +0 -0
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/tests/test_client.py +0 -0
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/tests/test_formatters.py +0 -0
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/tests/test_formatters_property.py +0 -0
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/tests/test_market.py +0 -0
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/tests/test_order.py +0 -0
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/tests/test_stock.py +0 -0
- {kiwoom_cli-2.0.0 → kiwoom_cli-2.1.0}/tests/test_us.py +0 -0
- {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.
|
|
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 |
|
|
495
|
-
| 토큰 | OS 키체인
|
|
496
|
-
| config.toml | 도메인, 계좌번호만 |
|
|
491
|
+
| 항목 | 저장 방식 | 프롬프트 |
|
|
492
|
+
| ------------------ | ------------------ | :------: |
|
|
493
|
+
| appkey / secretkey | OS 키체인 | X |
|
|
494
|
+
| 토큰 | OS 키체인 | X |
|
|
495
|
+
| config.toml | 도메인, 계좌번호만 | X |
|
|
497
496
|
|
|
498
|
-
-
|
|
499
|
-
-
|
|
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 |
|
|
457
|
-
| 토큰 | OS 키체인
|
|
458
|
-
| config.toml | 도메인, 계좌번호만 |
|
|
454
|
+
| 항목 | 저장 방식 | 프롬프트 |
|
|
455
|
+
| ------------------ | ------------------ | :------: |
|
|
456
|
+
| appkey / secretkey | OS 키체인 | X |
|
|
457
|
+
| 토큰 | OS 키체인 | X |
|
|
458
|
+
| config.toml | 도메인, 계좌번호만 | X |
|
|
459
459
|
|
|
460
|
-
-
|
|
461
|
-
-
|
|
462
|
-
-
|
|
463
|
-
- `keyring.get_password()`로 직접 접근해도 암호화된 값만 반환
|
|
460
|
+
- 모든 명령어는 비밀번호/생체인증 프롬프트 없이 동작 (AI 에이전트·자동화 친화적)
|
|
461
|
+
- 앱 자체 암호화 계층은 의도적으로 두지 않음 — 추가 계층은 명령마다 잠금 해제 프롬프트를 요구하게 되어 CLI 사용성을 해침
|
|
462
|
+
- v2.0 이하에서 업그레이드한 경우: 암호화 저장소 형식이 제거되어 `kiwoom config setup`을 한 번 다시 실행해야 합니다
|
|
464
463
|
|
|
465
464
|
## License
|
|
466
465
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
"""Configuration management for Kiwoom CLI.
|
|
2
2
|
|
|
3
|
-
Priority: environment variables >
|
|
3
|
+
Priority: environment variables > OS keychain > config.toml
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
199
|
+
keyring.set_password(KEYRING_SERVICE, "default:appkey", ak)
|
|
159
200
|
if sk:
|
|
160
|
-
|
|
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
|
|
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
|
|
83
|
-
if config.
|
|
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.
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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]설정됨 (
|
|
152
|
+
console.print(f" App Key: {'[dim]설정됨 (키체인)[/]' if configured else '(미설정)'}")
|
|
152
153
|
console.print(f" 계좌번호: {profile_cfg.get('account', '(미설정)')}")
|
|
153
|
-
console.print(f" 보안: [bold]{'
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
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.
|
|
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 |
|
|
495
|
-
| 토큰 | OS 키체인
|
|
496
|
-
| config.toml | 도메인, 계좌번호만 |
|
|
491
|
+
| 항목 | 저장 방식 | 프롬프트 |
|
|
492
|
+
| ------------------ | ------------------ | :------: |
|
|
493
|
+
| appkey / secretkey | OS 키체인 | X |
|
|
494
|
+
| 토큰 | OS 키체인 | X |
|
|
495
|
+
| config.toml | 도메인, 계좌번호만 | X |
|
|
497
496
|
|
|
498
|
-
-
|
|
499
|
-
-
|
|
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
|
|
@@ -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
|
|
5
|
-
|
|
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
|
|
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
|
|
37
|
-
"""config.toml [auth] section
|
|
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.
|
|
47
|
-
assert config.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|