agent-core-credentials 0.8.2__py3-none-any.whl
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.
- agent_core_credentials/__init__.py +67 -0
- agent_core_credentials/cli.py +156 -0
- agent_core_credentials/master_password.py +217 -0
- agent_core_credentials/models.py +27 -0
- agent_core_credentials/py.typed +0 -0
- agent_core_credentials/secrets.py +66 -0
- agent_core_credentials/store.py +134 -0
- agent_core_credentials-0.8.2.dist-info/METADATA +11 -0
- agent_core_credentials-0.8.2.dist-info/RECORD +11 -0
- agent_core_credentials-0.8.2.dist-info/WHEEL +4 -0
- agent_core_credentials-0.8.2.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""agent_core_credentials — KeePass-backed credential vault.
|
|
2
|
+
|
|
3
|
+
Public API for credential operations. Used by the CLI and available
|
|
4
|
+
for direct import elsewhere.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from agent_core_credentials.master_password import get_master_password, set_master_password
|
|
13
|
+
from agent_core_credentials.models import Credential, CredentialSummary
|
|
14
|
+
from agent_core_credentials.secrets import SecretNotFoundError, get
|
|
15
|
+
from agent_core_credentials.store import CredentialStore
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Credential",
|
|
19
|
+
"CredentialSummary",
|
|
20
|
+
"SecretNotFoundError",
|
|
21
|
+
"default_vault_path",
|
|
22
|
+
"delete_credential",
|
|
23
|
+
"get",
|
|
24
|
+
"get_credential",
|
|
25
|
+
"get_master_password",
|
|
26
|
+
"list_credentials",
|
|
27
|
+
"set_credential",
|
|
28
|
+
"set_master_password",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def default_vault_path() -> Path:
|
|
33
|
+
"""Resolve the default vault path.
|
|
34
|
+
|
|
35
|
+
Honours the AGENT_CORE_VAULT_PATH env var if set; otherwise falls
|
|
36
|
+
back to ~/.agent-core/credentials.kdbx.
|
|
37
|
+
"""
|
|
38
|
+
override = os.environ.get("AGENT_CORE_VAULT_PATH")
|
|
39
|
+
if override:
|
|
40
|
+
return Path(override)
|
|
41
|
+
return Path.home() / ".agent-core" / "credentials.kdbx"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_credential(service: str) -> Credential | None:
|
|
45
|
+
"""Retrieve a credential by service name."""
|
|
46
|
+
return CredentialStore(default_vault_path()).get(service)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def set_credential(
|
|
50
|
+
service: str,
|
|
51
|
+
username: str,
|
|
52
|
+
password: str,
|
|
53
|
+
url: str = "",
|
|
54
|
+
notes: str = "",
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Store or overwrite a credential."""
|
|
57
|
+
CredentialStore(default_vault_path()).set(service, username, password, url, notes)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def list_credentials() -> list[CredentialSummary]:
|
|
61
|
+
"""List all stored credentials without passwords."""
|
|
62
|
+
return CredentialStore(default_vault_path()).list()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def delete_credential(service: str) -> bool:
|
|
66
|
+
"""Delete a credential. Returns True if found and deleted."""
|
|
67
|
+
return CredentialStore(default_vault_path()).delete(service)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""agent-core creds CLI — manage stored credentials."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from pykeepass import create_database # type: ignore[import-untyped]
|
|
11
|
+
from rich import print as rprint
|
|
12
|
+
|
|
13
|
+
from agent_core_credentials import default_vault_path
|
|
14
|
+
from agent_core_credentials.master_password import set_master_password
|
|
15
|
+
from agent_core_credentials.store import CredentialStore
|
|
16
|
+
|
|
17
|
+
creds_app = typer.Typer(
|
|
18
|
+
name="creds",
|
|
19
|
+
help="Manage stored credentials.",
|
|
20
|
+
no_args_is_help=True,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# Default paths — overridden in tests via monkeypatch
|
|
24
|
+
_vault_path = default_vault_path()
|
|
25
|
+
_env_path = Path.home() / ".agent-core" / ".env"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _load_env() -> None:
|
|
29
|
+
"""Load .env into the process environment if not already set."""
|
|
30
|
+
if os.environ.get("AGENT_CORE_VAULT_PASSWORD"):
|
|
31
|
+
return
|
|
32
|
+
if _env_path.exists():
|
|
33
|
+
for line in _env_path.read_text().splitlines():
|
|
34
|
+
if "=" in line and not line.startswith("#"):
|
|
35
|
+
key, _, value = line.partition("=")
|
|
36
|
+
os.environ.setdefault(key.strip(), value.strip())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _store() -> CredentialStore:
|
|
40
|
+
"""Create a CredentialStore for the current vault path."""
|
|
41
|
+
_load_env()
|
|
42
|
+
return CredentialStore(_vault_path)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@creds_app.command("init")
|
|
46
|
+
def init_vault() -> None:
|
|
47
|
+
"""Initialize the credential vault with a master password."""
|
|
48
|
+
if _vault_path.exists():
|
|
49
|
+
rprint(f"[red]Credential vault already exists at {_vault_path}[/red]")
|
|
50
|
+
raise typer.Exit(1)
|
|
51
|
+
|
|
52
|
+
password = typer.prompt("Master password", hide_input=True)
|
|
53
|
+
confirm = typer.prompt("Confirm password", hide_input=True)
|
|
54
|
+
|
|
55
|
+
if password != confirm:
|
|
56
|
+
rprint("[red]Passwords do not match.[/red]")
|
|
57
|
+
raise typer.Exit(1)
|
|
58
|
+
|
|
59
|
+
_vault_path.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
create_database(str(_vault_path), password=password)
|
|
61
|
+
|
|
62
|
+
set_master_password(_vault_path, password)
|
|
63
|
+
|
|
64
|
+
rprint(f"[green]Created credential vault at {_vault_path}[/green]")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@creds_app.command("set")
|
|
68
|
+
def set_credential(
|
|
69
|
+
service: str = typer.Argument(help="Service name (e.g. apex, etsy)"),
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Store a credential with secure password input."""
|
|
72
|
+
username = typer.prompt("Username")
|
|
73
|
+
password = typer.prompt("Password", hide_input=True)
|
|
74
|
+
url = typer.prompt("URL (optional)", default="")
|
|
75
|
+
notes = typer.prompt("Notes (optional)", default="")
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
_store().set(service, username, password, url, notes)
|
|
79
|
+
except ValueError as e:
|
|
80
|
+
rprint(f"[red]{e}[/red]")
|
|
81
|
+
raise typer.Exit(1) from None
|
|
82
|
+
|
|
83
|
+
rprint(f"[green]Saved {service}[/green]")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@creds_app.command("get")
|
|
87
|
+
def get_credential(
|
|
88
|
+
service: str = typer.Argument(help="Service name to retrieve"),
|
|
89
|
+
as_json: bool = typer.Option(False, "--json", help="Output as JSON (metadata only, no secret)"),
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Retrieve a stored credential."""
|
|
92
|
+
try:
|
|
93
|
+
cred = _store().get(service)
|
|
94
|
+
except ValueError as e:
|
|
95
|
+
rprint(f"[red]{e}[/red]")
|
|
96
|
+
raise typer.Exit(1) from None
|
|
97
|
+
|
|
98
|
+
if cred is None:
|
|
99
|
+
rprint(f"[red]No credential found for {service}[/red]")
|
|
100
|
+
raise typer.Exit(1)
|
|
101
|
+
|
|
102
|
+
if as_json:
|
|
103
|
+
print(json.dumps({"name": cred.service, "exists": True, "length": len(cred.password)}))
|
|
104
|
+
else:
|
|
105
|
+
rprint(f"[bold]Service:[/bold] {cred.service}")
|
|
106
|
+
rprint(f"[bold]Length:[/bold] {len(cred.password)} chars")
|
|
107
|
+
if cred.mtime is not None:
|
|
108
|
+
rprint(f"[bold]Modified:[/bold] {cred.mtime.strftime('%Y-%m-%d %H:%M UTC')}")
|
|
109
|
+
else:
|
|
110
|
+
rprint("[bold]Modified:[/bold] (unknown)")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@creds_app.command("list")
|
|
114
|
+
def list_credentials(
|
|
115
|
+
as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
116
|
+
) -> None:
|
|
117
|
+
"""List all stored credentials."""
|
|
118
|
+
try:
|
|
119
|
+
summaries = _store().list()
|
|
120
|
+
except ValueError as e:
|
|
121
|
+
rprint(f"[red]{e}[/red]")
|
|
122
|
+
raise typer.Exit(1) from None
|
|
123
|
+
|
|
124
|
+
if as_json:
|
|
125
|
+
print(
|
|
126
|
+
json.dumps(
|
|
127
|
+
[
|
|
128
|
+
{"service": s.service, "username": s.username, "url": s.url}
|
|
129
|
+
for s in summaries
|
|
130
|
+
]
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
else:
|
|
134
|
+
if not summaries:
|
|
135
|
+
rprint("[yellow]No credentials stored.[/yellow]")
|
|
136
|
+
return
|
|
137
|
+
for s in summaries:
|
|
138
|
+
rprint(s.service)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@creds_app.command("delete")
|
|
142
|
+
def delete_credential(
|
|
143
|
+
service: str = typer.Argument(help="Service name to delete"),
|
|
144
|
+
) -> None:
|
|
145
|
+
"""Delete a stored credential."""
|
|
146
|
+
try:
|
|
147
|
+
deleted = _store().delete(service)
|
|
148
|
+
except ValueError as e:
|
|
149
|
+
rprint(f"[red]{e}[/red]")
|
|
150
|
+
raise typer.Exit(1) from None
|
|
151
|
+
|
|
152
|
+
if not deleted:
|
|
153
|
+
rprint(f"[red]No credential found for {service}[/red]")
|
|
154
|
+
raise typer.Exit(1)
|
|
155
|
+
|
|
156
|
+
rprint(f"[green]Deleted {service}[/green]")
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Master password store — OS keyring (primary) + owner-only encrypted file (fallback).
|
|
2
|
+
|
|
3
|
+
Strategy pattern: ``_KeyringStore`` and ``EncryptedFileStore`` each implement
|
|
4
|
+
``get() -> str | None`` and ``set(password: str) -> None``. ``_get_backend()``
|
|
5
|
+
selects the strategy at runtime by probing the keyring. ``get_master_password``
|
|
6
|
+
and ``set_master_password`` are the public entry-points.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import keyring
|
|
17
|
+
import keyring.errors
|
|
18
|
+
from cryptography.fernet import Fernet, InvalidToken
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Constants
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
KEYRING_SERVICE: str = "agent-core"
|
|
27
|
+
_ENV_PATH: Path = Path.home() / ".agent-core" / ".env"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Helpers
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _username_for(vault_path: Path) -> str:
|
|
36
|
+
"""Return the per-vault keyring username (absolute path string)."""
|
|
37
|
+
return str(vault_path.resolve())
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _hash_for(vault_path: Path) -> str:
|
|
41
|
+
"""Return first 12 hex chars of sha256(resolved vault path) for filename suffixes."""
|
|
42
|
+
digest = hashlib.sha256(str(vault_path.resolve()).encode()).hexdigest()
|
|
43
|
+
return digest[:12]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# EncryptedFileStore — owner-only Fernet-encrypted file fallback
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class EncryptedFileStore:
|
|
52
|
+
"""Owner-only encrypted file fallback for headless Linux/CI environments.
|
|
53
|
+
|
|
54
|
+
Two files are written next to the vault:
|
|
55
|
+
- ``_key_path``: 32-byte Fernet key (generated once, chmod 0o600).
|
|
56
|
+
- ``_data_path``: Fernet-encrypted master password (chmod 0o600).
|
|
57
|
+
|
|
58
|
+
Both files use a hash suffix so multiple vaults in the same directory
|
|
59
|
+
don't collide.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, vault_path: Path) -> None:
|
|
63
|
+
h = _hash_for(vault_path)
|
|
64
|
+
self._key_path: Path = vault_path.parent / f".vault-key-{h}"
|
|
65
|
+
self._data_path: Path = vault_path.parent / f".vault-pass-{h}"
|
|
66
|
+
|
|
67
|
+
def _load_key(self) -> bytes:
|
|
68
|
+
"""Load or generate the Fernet key. Writes 0o600 on creation."""
|
|
69
|
+
if self._key_path.exists():
|
|
70
|
+
return self._key_path.read_bytes()
|
|
71
|
+
key = Fernet.generate_key()
|
|
72
|
+
self._key_path.write_bytes(key)
|
|
73
|
+
os.chmod(self._key_path, 0o600)
|
|
74
|
+
return key
|
|
75
|
+
|
|
76
|
+
def get(self) -> str | None:
|
|
77
|
+
"""Return the decrypted password, or None if absent or unreadable."""
|
|
78
|
+
if not self._data_path.exists():
|
|
79
|
+
return None
|
|
80
|
+
key = self._load_key()
|
|
81
|
+
f = Fernet(key)
|
|
82
|
+
try:
|
|
83
|
+
ciphertext = self._data_path.read_bytes()
|
|
84
|
+
return f.decrypt(ciphertext).decode("utf-8")
|
|
85
|
+
except InvalidToken:
|
|
86
|
+
# Key was regenerated (old key deleted); old ciphertext is unreadable.
|
|
87
|
+
logger.debug(
|
|
88
|
+
"EncryptedFileStore: InvalidToken — key/data file mismatch; returning None",
|
|
89
|
+
)
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
def set(self, password: str) -> None:
|
|
93
|
+
"""Encrypt and persist the password. Both files are chmod 0o600."""
|
|
94
|
+
key = self._load_key()
|
|
95
|
+
f = Fernet(key)
|
|
96
|
+
ciphertext = f.encrypt(password.encode("utf-8"))
|
|
97
|
+
self._data_path.write_bytes(ciphertext)
|
|
98
|
+
os.chmod(self._data_path, 0o600)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
# _KeyringStore — OS keyring (primary) backend
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class _KeyringStore:
|
|
107
|
+
"""OS keyring backend (macOS Keychain, Windows Credential Manager, Secret Service)."""
|
|
108
|
+
|
|
109
|
+
def __init__(self, vault_path: Path) -> None:
|
|
110
|
+
self._username: str = _username_for(vault_path)
|
|
111
|
+
|
|
112
|
+
def get(self) -> str | None:
|
|
113
|
+
"""Return the password from the keyring, or None if not stored."""
|
|
114
|
+
return keyring.get_password(KEYRING_SERVICE, self._username)
|
|
115
|
+
|
|
116
|
+
def set(self, password: str) -> None:
|
|
117
|
+
"""Persist the password in the keyring."""
|
|
118
|
+
keyring.set_password(KEYRING_SERVICE, self._username, password)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
# Backend factory
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _get_backend(vault_path: Path) -> _KeyringStore | EncryptedFileStore:
|
|
127
|
+
"""Return the appropriate backend by probing the OS keyring.
|
|
128
|
+
|
|
129
|
+
A harmless read is performed on a non-secret probe key. If ``get_password``
|
|
130
|
+
returns ``None`` (no entry exists — the normal case) the keyring is
|
|
131
|
+
functional; ``_KeyringStore`` is returned. If it raises any exception
|
|
132
|
+
(``NoKeyringError`` on headless Linux/CI, or any other error), fall back
|
|
133
|
+
to ``EncryptedFileStore``.
|
|
134
|
+
"""
|
|
135
|
+
try:
|
|
136
|
+
keyring.get_password("__agent-core-probe__", "__probe__")
|
|
137
|
+
logger.debug("_get_backend: OS keyring available; using _KeyringStore")
|
|
138
|
+
return _KeyringStore(vault_path)
|
|
139
|
+
except Exception as exc: # noqa: BLE001
|
|
140
|
+
logger.debug("_get_backend: keyring unavailable (%s); using EncryptedFileStore", exc, exc_info=True)
|
|
141
|
+
return EncryptedFileStore(vault_path)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
# .env migration helpers
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _read_env_file_password(env_path: Path) -> str | None:
|
|
150
|
+
"""Parse env_path line by line; return AGENT_CORE_VAULT_PASSWORD value or None."""
|
|
151
|
+
if not env_path.exists():
|
|
152
|
+
return None
|
|
153
|
+
for line in env_path.read_text().splitlines():
|
|
154
|
+
stripped = line.strip()
|
|
155
|
+
if stripped.startswith("#"):
|
|
156
|
+
continue
|
|
157
|
+
if stripped.startswith("AGENT_CORE_VAULT_PASSWORD="):
|
|
158
|
+
value = stripped[len("AGENT_CORE_VAULT_PASSWORD="):]
|
|
159
|
+
return value if value else None
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _scrub_env_file_password(env_path: Path) -> None:
|
|
164
|
+
"""Remove all non-comment AGENT_CORE_VAULT_PASSWORD= lines from env_path.
|
|
165
|
+
|
|
166
|
+
No-op if the file does not exist. Idempotent.
|
|
167
|
+
"""
|
|
168
|
+
if not env_path.exists():
|
|
169
|
+
return
|
|
170
|
+
original = env_path.read_text()
|
|
171
|
+
lines = original.splitlines(keepends=True)
|
|
172
|
+
kept = [
|
|
173
|
+
line
|
|
174
|
+
for line in lines
|
|
175
|
+
if not (
|
|
176
|
+
not line.strip().startswith("#")
|
|
177
|
+
and "AGENT_CORE_VAULT_PASSWORD=" in line
|
|
178
|
+
)
|
|
179
|
+
]
|
|
180
|
+
env_path.write_text("".join(kept))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
# Public API
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def get_master_password(
|
|
189
|
+
vault_path: Path,
|
|
190
|
+
env_path: Path = _ENV_PATH,
|
|
191
|
+
) -> str | None:
|
|
192
|
+
"""Return the master password for vault_path, migrating from .env if needed.
|
|
193
|
+
|
|
194
|
+
Resolution order:
|
|
195
|
+
1. Backend (keyring or encrypted file).
|
|
196
|
+
2. Plaintext ``AGENT_CORE_VAULT_PASSWORD`` in ``env_path`` — triggers a
|
|
197
|
+
one-time migration: write to backend, scrub from ``.env``.
|
|
198
|
+
3. Return ``None`` if neither source has a password.
|
|
199
|
+
"""
|
|
200
|
+
backend = _get_backend(vault_path)
|
|
201
|
+
stored = backend.get()
|
|
202
|
+
if stored:
|
|
203
|
+
return stored
|
|
204
|
+
|
|
205
|
+
env_password = _read_env_file_password(env_path)
|
|
206
|
+
if env_password:
|
|
207
|
+
logger.debug("get_master_password: migrating password from .env to backend")
|
|
208
|
+
backend.set(env_password)
|
|
209
|
+
_scrub_env_file_password(env_path)
|
|
210
|
+
return env_password
|
|
211
|
+
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def set_master_password(vault_path: Path, password: str) -> None:
|
|
216
|
+
"""Persist the master password for vault_path in the active backend."""
|
|
217
|
+
_get_backend(vault_path).set(password)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Credential data models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Credential:
|
|
11
|
+
"""A stored credential with all fields."""
|
|
12
|
+
|
|
13
|
+
service: str
|
|
14
|
+
username: str
|
|
15
|
+
password: str
|
|
16
|
+
url: str = ""
|
|
17
|
+
notes: str = ""
|
|
18
|
+
mtime: datetime | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class CredentialSummary:
|
|
23
|
+
"""A credential summary without the password."""
|
|
24
|
+
|
|
25
|
+
service: str
|
|
26
|
+
username: str
|
|
27
|
+
url: str = ""
|
|
File without changes
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""secrets.get() — vault-first, env-fallback secret accessor.
|
|
2
|
+
|
|
3
|
+
Resolution order:
|
|
4
|
+
1. Vault entry with ``service == name`` — returns ``cred.password``.
|
|
5
|
+
2. ``os.environ.get(name)`` — env fallback for migration safety.
|
|
6
|
+
3. Raises ``SecretNotFoundError`` if neither source has the secret.
|
|
7
|
+
|
|
8
|
+
Vault failures (no vault file, bad password, any pykeepass error) are caught,
|
|
9
|
+
logged at DEBUG, and fall through silently to the env fallback.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SecretNotFoundError(Exception):
|
|
21
|
+
"""Raised when neither the vault nor os.environ contains the named secret."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _open_store():
|
|
25
|
+
"""Open the credential store against the default vault path.
|
|
26
|
+
|
|
27
|
+
Isolated into its own function so tests can monkeypatch it without
|
|
28
|
+
touching the real vault.
|
|
29
|
+
"""
|
|
30
|
+
from agent_core_credentials import default_vault_path
|
|
31
|
+
from agent_core_credentials.store import CredentialStore
|
|
32
|
+
|
|
33
|
+
return CredentialStore(default_vault_path())
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get(name: str) -> str:
|
|
37
|
+
"""Return the secret named *name*.
|
|
38
|
+
|
|
39
|
+
Resolution order:
|
|
40
|
+
1. Vault entry whose ``service`` field equals *name*.
|
|
41
|
+
2. ``os.environ[name]`` (migration fallback — works while vault is
|
|
42
|
+
being populated).
|
|
43
|
+
3. Raises :exc:`SecretNotFoundError`.
|
|
44
|
+
|
|
45
|
+
Any exception thrown while opening or querying the vault is caught
|
|
46
|
+
and logged at DEBUG so that a missing / locked vault never hard-fails
|
|
47
|
+
a call that could succeed from the environment.
|
|
48
|
+
"""
|
|
49
|
+
# --- vault path ---
|
|
50
|
+
try:
|
|
51
|
+
store = _open_store()
|
|
52
|
+
cred = store.get(name)
|
|
53
|
+
if cred is not None and cred.password:
|
|
54
|
+
return cred.password
|
|
55
|
+
except Exception:
|
|
56
|
+
log.debug("vault lookup failed for %r — falling through to env", name, exc_info=True)
|
|
57
|
+
|
|
58
|
+
# --- env fallback ---
|
|
59
|
+
value = os.environ.get(name)
|
|
60
|
+
if value:
|
|
61
|
+
return value
|
|
62
|
+
|
|
63
|
+
raise SecretNotFoundError(
|
|
64
|
+
f"Secret {name!r} not found in vault or environment. "
|
|
65
|
+
"Populate the vault entry or set the env var before starting."
|
|
66
|
+
)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""KeePass credential store — pykeepass wrapper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pykeepass import PyKeePass, create_database # type: ignore[import-untyped]
|
|
8
|
+
|
|
9
|
+
from agent_core_credentials.master_password import get_master_password
|
|
10
|
+
from agent_core_credentials.models import Credential, CredentialSummary
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CredentialStore:
|
|
14
|
+
"""CRUD operations on an encrypted KeePass vault.
|
|
15
|
+
|
|
16
|
+
Each operation opens the vault, performs the action, and saves/closes.
|
|
17
|
+
The master password is fetched from the OS keyring or owner-only encrypted
|
|
18
|
+
file fallback via ``get_master_password()``.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
vault_path: Path to the .kdbx file. Created on first write.
|
|
22
|
+
_master_password: Optional DI override for the master password (tests).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, vault_path: Path, *, _master_password: str | None = None) -> None:
|
|
26
|
+
"""Initialize the store with a path to the vault file."""
|
|
27
|
+
self.vault_path = vault_path
|
|
28
|
+
self._password_override = _master_password
|
|
29
|
+
|
|
30
|
+
def _get_password(self) -> str:
|
|
31
|
+
"""Return the master password from override DI or the active backend."""
|
|
32
|
+
if self._password_override is not None:
|
|
33
|
+
return self._password_override
|
|
34
|
+
result = get_master_password(self.vault_path)
|
|
35
|
+
if not result:
|
|
36
|
+
msg = (
|
|
37
|
+
"No master password found. Run 'agent-core-creds init' to initialize "
|
|
38
|
+
"the vault, or ensure the OS keyring is accessible."
|
|
39
|
+
)
|
|
40
|
+
raise ValueError(msg)
|
|
41
|
+
return result
|
|
42
|
+
|
|
43
|
+
def _open(self) -> PyKeePass:
|
|
44
|
+
"""Open the existing vault; raise ``FileNotFoundError`` if it's absent.
|
|
45
|
+
|
|
46
|
+
Reads and deletes must NOT create a vault — silently creating an empty
|
|
47
|
+
``.kdbx`` on a read masks a missing or misconfigured vault (and can
|
|
48
|
+
shadow the real one). Only the write path may create; see
|
|
49
|
+
:meth:`_open_or_create`.
|
|
50
|
+
"""
|
|
51
|
+
# Validate the master-password config first (a config error is more
|
|
52
|
+
# fundamental), then require the vault to already exist.
|
|
53
|
+
password = self._get_password()
|
|
54
|
+
if not self.vault_path.exists():
|
|
55
|
+
raise FileNotFoundError(
|
|
56
|
+
f"credentials vault not found at {self.vault_path}; "
|
|
57
|
+
"set a credential (or run the init command) to create it"
|
|
58
|
+
)
|
|
59
|
+
return PyKeePass(str(self.vault_path), password=password)
|
|
60
|
+
|
|
61
|
+
def _open_or_create(self) -> PyKeePass:
|
|
62
|
+
"""Open the vault, creating an empty one if it's absent.
|
|
63
|
+
|
|
64
|
+
Only the explicit write path (:meth:`set`) may call this — a missing
|
|
65
|
+
vault on first write is expected.
|
|
66
|
+
"""
|
|
67
|
+
password = self._get_password()
|
|
68
|
+
if not self.vault_path.exists():
|
|
69
|
+
return create_database(str(self.vault_path), password=password)
|
|
70
|
+
return PyKeePass(str(self.vault_path), password=password)
|
|
71
|
+
|
|
72
|
+
def get(self, service: str) -> Credential | None:
|
|
73
|
+
"""Retrieve a credential by service name."""
|
|
74
|
+
kp = self._open()
|
|
75
|
+
entry = kp.find_entries(title=service, first=True)
|
|
76
|
+
if entry is None:
|
|
77
|
+
return None
|
|
78
|
+
return Credential(
|
|
79
|
+
service=entry.title,
|
|
80
|
+
username=entry.username or "",
|
|
81
|
+
password=entry.password or "",
|
|
82
|
+
url=entry.url or "",
|
|
83
|
+
notes=entry.notes or "",
|
|
84
|
+
mtime=entry.mtime,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def set(
|
|
88
|
+
self,
|
|
89
|
+
service: str,
|
|
90
|
+
username: str,
|
|
91
|
+
password: str,
|
|
92
|
+
url: str = "",
|
|
93
|
+
notes: str = "",
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Store or overwrite a credential (creates the vault on first write)."""
|
|
96
|
+
kp = self._open_or_create()
|
|
97
|
+
existing = kp.find_entries(title=service, first=True)
|
|
98
|
+
if existing:
|
|
99
|
+
existing.username = username
|
|
100
|
+
existing.password = password
|
|
101
|
+
existing.url = url
|
|
102
|
+
existing.notes = notes
|
|
103
|
+
else:
|
|
104
|
+
kp.add_entry(
|
|
105
|
+
kp.root_group,
|
|
106
|
+
title=service,
|
|
107
|
+
username=username,
|
|
108
|
+
password=password,
|
|
109
|
+
url=url,
|
|
110
|
+
notes=notes,
|
|
111
|
+
)
|
|
112
|
+
kp.save()
|
|
113
|
+
|
|
114
|
+
def list(self) -> list[CredentialSummary]:
|
|
115
|
+
"""List all stored credentials without passwords."""
|
|
116
|
+
kp = self._open()
|
|
117
|
+
return [
|
|
118
|
+
CredentialSummary(
|
|
119
|
+
service=entry.title,
|
|
120
|
+
username=entry.username or "",
|
|
121
|
+
url=entry.url or "",
|
|
122
|
+
)
|
|
123
|
+
for entry in kp.entries
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
def delete(self, service: str) -> bool:
|
|
127
|
+
"""Delete a credential by service name."""
|
|
128
|
+
kp = self._open()
|
|
129
|
+
entry = kp.find_entries(title=service, first=True)
|
|
130
|
+
if entry is None:
|
|
131
|
+
return False
|
|
132
|
+
kp.delete_entry(entry)
|
|
133
|
+
kp.save()
|
|
134
|
+
return True
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agent-core-credentials
|
|
3
|
+
Version: 0.8.2
|
|
4
|
+
Summary: KeePass-backed credential vault for agent-core agents
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: cryptography>=41.0
|
|
8
|
+
Requires-Dist: keyring>=24.0
|
|
9
|
+
Requires-Dist: pykeepass>=4.1.1.post1
|
|
10
|
+
Requires-Dist: rich>=13.0
|
|
11
|
+
Requires-Dist: typer>=0.12
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
agent_core_credentials/__init__.py,sha256=rBEA_pPjRPkLBfbCWzgQIW3-_brN7Uj2QxyfLwW-MJc,1927
|
|
2
|
+
agent_core_credentials/cli.py,sha256=wEryTVD0R5o14vHgH31ROsN2MPm5gziC_3PQB2ArKyw,4807
|
|
3
|
+
agent_core_credentials/master_password.py,sha256=DbzRFE_XouYUhmvibLckbwOhVIULNIGaawFst0kwr6I,7844
|
|
4
|
+
agent_core_credentials/models.py,sha256=qk3SrE0-zdxIAZKS0ggylLn5k99V75imXn_6_D1Jaig,480
|
|
5
|
+
agent_core_credentials/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
agent_core_credentials/secrets.py,sha256=GZJm56k9BcR2ZtPlQr_5_st52b7YzjjBzo9lCn4xME8,2081
|
|
7
|
+
agent_core_credentials/store.py,sha256=XgwiPlyr53F3PzUiSWTpoltc5p4T7cnIccMDyg9-xwI,4834
|
|
8
|
+
agent_core_credentials-0.8.2.dist-info/METADATA,sha256=3CyNeGgTtQFlPgY-aVg_lDyynWlouNfXwbaYAWBg7Xc,331
|
|
9
|
+
agent_core_credentials-0.8.2.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
10
|
+
agent_core_credentials-0.8.2.dist-info/entry_points.txt,sha256=eaSA66yx6RVt8WTmN4OZS3KYCAGuuioUMJlH08a7xpQ,74
|
|
11
|
+
agent_core_credentials-0.8.2.dist-info/RECORD,,
|