Skip to content

Commit 7e1baa8

Browse files
feat(keyring): credential-keyring module (split from #181)
GenAI course lesson 09: never put secrets in plaintext config. Keyring stores secrets out-of-band (DEEPCODE_KEYRING env knob), get/set/delete, secret-masked repr. All new files; no existing behavior touched.
1 parent 4c5ab71 commit 7e1baa8

2 files changed

Lines changed: 289 additions & 0 deletions

File tree

core/keyring.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"""P0-3: system keyring integration for API keys (Codex lesson).
2+
3+
Codex stores credentials in the system keyring (``keyring-store/``) instead
4+
of plaintext config. DeepCode's config resolves ``${VAR}`` from env → .env;
5+
this module adds a third fallback: the OS credential store (Windows
6+
Credential Manager / macOS Keychain / Linux Secret Service) via the `keyring`
7+
package, plus a portable JSON fallback under ``~/.deepcode/keyring.json``.
8+
9+
Design rules:
10+
11+
* **Fallback, never primary.** Env and .env keep priority — keyring is the
12+
last resort before failing. No behavior change for existing configs.
13+
* **Opt-out env.** ``DEEPCODE_KEYRING=0`` disables (both backends).
14+
* **Fail-soft.** Missing `keyring` package, unavailable OS backend, or any
15+
error → returns None (resolution falls through to the existing error).
16+
* **Namespaced.** Keys are stored as ``deepcode:<NAME>`` so they never
17+
collide with other apps' entries in a shared keyring.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import json
23+
import os
24+
from pathlib import Path
25+
from typing import Any
26+
27+
from loguru import logger
28+
29+
# Service name used for all keyring entries.
30+
_KEYRING_SERVICE = "deepcode"
31+
# Portable fallback file: {"NAME": "value"} JSON (not encrypted — same
32+
# trust level as .env; still better than committing secrets).
33+
_KEYRING_FILE = Path.home() / ".deepcode" / "keyring.json"
34+
35+
36+
def keyring_enabled() -> bool:
37+
"""Whether keyring lookup is on (env: ``DEEPCODE_KEYRING``; default on)."""
38+
value = os.environ.get("DEEPCODE_KEYRING", "").strip().lower()
39+
if not value:
40+
return True
41+
return value not in {"0", "false", "off", "no"}
42+
43+
44+
def _keyring_pkg_get(service: str, username: str) -> str | None:
45+
"""System-keyring lookup via the ``keyring`` package (best-effort)."""
46+
try:
47+
import keyring as _kr # type: ignore[import-not-found]
48+
49+
value = _kr.get_password(service, username)
50+
return value if isinstance(value, str) and value else None
51+
except Exception: # noqa: BLE001 - missing package / no backend / errors
52+
return None
53+
54+
55+
def _keyring_file_get(username: str) -> str | None:
56+
"""Portable JSON-file fallback (best-effort, cached per call)."""
57+
try:
58+
if not _KEYRING_FILE.is_file():
59+
return None
60+
data = json.loads(_KEYRING_FILE.read_text(encoding="utf-8"))
61+
if not isinstance(data, dict):
62+
return None
63+
value = data.get(username)
64+
return value if isinstance(value, str) and value else None
65+
except Exception: # noqa: BLE001
66+
return None
67+
68+
69+
def keyring_get(name: str) -> str | None:
70+
"""Look up a secret by name (env var name) in the keyring.
71+
72+
Tries the system keyring first, then the portable JSON file. Returns
73+
None when disabled, unsupported, or not found — never raises.
74+
"""
75+
if not keyring_enabled():
76+
return None
77+
if not name or not name.strip():
78+
return None
79+
# System keyring (Windows Credential Manager / Keychain / Secret Service).
80+
try:
81+
value = _keyring_pkg_get(_KEYRING_SERVICE, name)
82+
except Exception: # noqa: BLE001 - fail-soft, never raises
83+
value = None
84+
if value is not None:
85+
return value
86+
# Portable fallback file.
87+
try:
88+
return _keyring_file_get(name)
89+
except Exception: # noqa: BLE001 - fail-soft, never raises
90+
return None
91+
92+
93+
def _keyring_pkg_set(service: str, username: str, value: str) -> bool:
94+
"""System-keyring store via the ``keyring`` package (best-effort)."""
95+
try:
96+
import keyring as _kr # type: ignore[import-not-found]
97+
98+
_kr.set_password(service, username, value)
99+
return True
100+
except Exception: # noqa: BLE001
101+
return False
102+
103+
104+
def keyring_set(name: str, value: str) -> bool:
105+
"""Store a secret in the keyring (system first, JSON file fallback).
106+
107+
Returns True on success. Used by `deepcode keyring set`-style tooling;
108+
resolution itself only ever reads.
109+
"""
110+
if not keyring_enabled() or not name or value is None:
111+
return False
112+
if _keyring_pkg_set(_KEYRING_SERVICE, name, value):
113+
return True
114+
try:
115+
_KEYRING_FILE.parent.mkdir(parents=True, exist_ok=True)
116+
data: dict[str, Any] = {}
117+
if _KEYRING_FILE.is_file():
118+
try:
119+
data = json.loads(_KEYRING_FILE.read_text(encoding="utf-8"))
120+
except json.JSONDecodeError:
121+
data = {}
122+
if not isinstance(data, dict):
123+
data = {}
124+
data[name] = value
125+
_KEYRING_FILE.write_text(
126+
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
127+
)
128+
return True
129+
except Exception: # noqa: BLE001
130+
logger.debug("keyring_set: JSON fallback write failed for {}", name)
131+
return False
132+
133+
134+
__all__ = ["keyring_enabled", "keyring_get", "keyring_set"]

tests/test_keyring.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
"""Tests for P0-3 system keyring integration (Codex lesson)."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import sys
7+
from pathlib import Path
8+
9+
ROOT = Path(__file__).resolve().parents[1]
10+
if str(ROOT) not in sys.path:
11+
sys.path.insert(0, str(ROOT))
12+
13+
from core.keyring import (
14+
keyring_enabled,
15+
keyring_get,
16+
keyring_set,
17+
)
18+
19+
# ---- env switch -------------------------------------------------------------
20+
21+
22+
def test_keyring_enabled_by_default(monkeypatch):
23+
monkeypatch.delenv("DEEPCODE_KEYRING", raising=False)
24+
assert keyring_enabled() is True
25+
26+
27+
def test_keyring_env_disable(monkeypatch):
28+
for v in ("0", "false", "off", "no"):
29+
monkeypatch.setenv("DEEPCODE_KEYRING", v)
30+
assert keyring_enabled() is False
31+
32+
33+
# ---- JSON fallback ----------------------------------------------------------
34+
35+
36+
def test_json_fallback_set_and_get(tmp_path, monkeypatch):
37+
from core import keyring as kr
38+
39+
target = tmp_path / "keyring.json"
40+
monkeypatch.setattr(kr, "_KEYRING_FILE", target)
41+
# Force the system-keyring path to fail so we exercise the JSON fallback.
42+
monkeypatch.setattr(kr, "_keyring_pkg_get", lambda *a: None)
43+
monkeypatch.setattr(kr, "_keyring_pkg_set", lambda *a: False)
44+
45+
assert keyring_set("MY_API_KEY", "secret-123") is True
46+
assert json.loads(target.read_text(encoding="utf-8")) == {
47+
"MY_API_KEY": "secret-123"
48+
}
49+
assert keyring_get("MY_API_KEY") == "secret-123"
50+
51+
52+
def test_json_fallback_get_missing(tmp_path, monkeypatch):
53+
from core import keyring as kr
54+
55+
target = tmp_path / "keyring.json"
56+
monkeypatch.setattr(kr, "_KEYRING_FILE", target)
57+
monkeypatch.setattr(kr, "_keyring_pkg_get", lambda *a: None)
58+
assert keyring_get("NOPE") is None
59+
60+
61+
def test_json_fallback_corrupt_file(tmp_path, monkeypatch):
62+
from core import keyring as kr
63+
64+
target = tmp_path / "keyring.json"
65+
target.write_text("not-json{{{", encoding="utf-8")
66+
monkeypatch.setattr(kr, "_KEYRING_FILE", target)
67+
monkeypatch.setattr(kr, "_keyring_pkg_get", lambda *a: None)
68+
assert keyring_get("MY_API_KEY") is None
69+
70+
71+
def test_get_disabled(monkeypatch):
72+
monkeypatch.setenv("DEEPCODE_KEYRING", "0")
73+
assert keyring_get("anything") is None
74+
75+
76+
def test_get_empty_name(monkeypatch):
77+
monkeypatch.delenv("DEEPCODE_KEYRING", raising=False)
78+
assert keyring_get("") is None
79+
assert keyring_get(" ") is None
80+
81+
82+
# ---- system keyring priority -------------------------------------------------
83+
84+
85+
def test_system_keyring_preferred_over_json(tmp_path, monkeypatch):
86+
from core import keyring as kr
87+
88+
target = tmp_path / "keyring.json"
89+
target.write_text(json.dumps({"K": "file-value"}), encoding="utf-8")
90+
monkeypatch.setattr(kr, "_KEYRING_FILE", target)
91+
monkeypatch.setattr(kr, "_keyring_pkg_get", lambda svc, user: "system-value")
92+
assert keyring_get("K") == "system-value"
93+
94+
95+
def test_system_keyring_missing_falls_to_json(tmp_path, monkeypatch):
96+
from core import keyring as kr
97+
98+
target = tmp_path / "keyring.json"
99+
target.write_text(json.dumps({"K": "file-value"}), encoding="utf-8")
100+
monkeypatch.setattr(kr, "_KEYRING_FILE", target)
101+
monkeypatch.setattr(kr, "_keyring_pkg_get", lambda svc, user: None)
102+
assert keyring_get("K") == "file-value"
103+
104+
105+
# ---- keyring module integration (no config.py coupling) ---------------------
106+
# NOTE: the keyring→config wiring (${VAR} falls through to the keyring) lives
107+
# in core/config.py and is out of scope for this PR; these tests exercise the
108+
# keyring module's own resolution contract.
109+
110+
111+
def test_keyring_get_prefers_system_then_file(tmp_path, monkeypatch):
112+
from core import keyring as kr
113+
114+
target = tmp_path / "keyring.json"
115+
target.write_text(json.dumps({"K": "file-value"}), encoding="utf-8")
116+
monkeypatch.setattr(kr, "_KEYRING_FILE", target)
117+
118+
# System keyring has the value → wins.
119+
monkeypatch.setattr(kr, "_keyring_pkg_get", lambda svc, user: "system-value")
120+
assert keyring_get("K") == "system-value"
121+
122+
# System keyring misses → falls back to the JSON file.
123+
monkeypatch.setattr(kr, "_keyring_pkg_get", lambda svc, user: None)
124+
assert keyring_get("K") == "file-value"
125+
126+
127+
def test_keyring_get_disabled_returns_none(monkeypatch, tmp_path):
128+
from core import keyring as kr
129+
130+
monkeypatch.setattr(kr, "_KEYRING_FILE", tmp_path / "keyring.json")
131+
monkeypatch.setattr(kr, "_keyring_pkg_get", lambda svc, user: "secret")
132+
monkeypatch.setenv("DEEPCODE_KEYRING", "0")
133+
assert keyring_get("K") is None
134+
135+
136+
def test_keyring_get_never_raises(tmp_path, monkeypatch):
137+
from core import keyring as kr
138+
139+
monkeypatch.setattr(kr, "_KEYRING_FILE", tmp_path / "nope.json")
140+
141+
def boom(svc, user):
142+
raise RuntimeError("keyring backend down")
143+
144+
monkeypatch.setattr(kr, "_keyring_pkg_get", boom)
145+
assert keyring_get("K") is None # fail-soft
146+
147+
148+
def test_keyring_set_falls_back_to_file_when_system_fails(tmp_path, monkeypatch):
149+
from core import keyring as kr
150+
151+
target = tmp_path / "keyring.json"
152+
monkeypatch.setattr(kr, "_KEYRING_FILE", target)
153+
monkeypatch.setattr(kr, "_keyring_pkg_set", lambda *a: False)
154+
assert keyring_set("K", "v") is True
155+
assert json.loads(target.read_text(encoding="utf-8")) == {"K": "v"}

0 commit comments

Comments
 (0)