mevault 0.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.
- mevault-0.1.0/.gitignore +8 -0
- mevault-0.1.0/PKG-INFO +104 -0
- mevault-0.1.0/README.md +81 -0
- mevault-0.1.0/pyproject.toml +34 -0
- mevault-0.1.0/src/mevault/__init__.py +53 -0
- mevault-0.1.0/src/mevault/client.py +343 -0
- mevault-0.1.0/src/mevault/exceptions.py +34 -0
- mevault-0.1.0/src/mevault/protocol.py +81 -0
- mevault-0.1.0/src/mevault/types.py +32 -0
- mevault-0.1.0/tests/test_client.py +358 -0
mevault-0.1.0/.gitignore
ADDED
mevault-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mevault
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MeVault Python SDK — request secrets from the local MeVault broker
|
|
5
|
+
Project-URL: Homepage, https://github.com/thecalebyte/mevault-cli
|
|
6
|
+
Project-URL: Documentation, https://github.com/thecalebyte/mevault-cli/blob/main/docs/sdk.md
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/thecalebyte/mevault-cli/issues
|
|
8
|
+
License: Apache-2.0
|
|
9
|
+
Keywords: credentials,mevault,secrets,security
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
13
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Security
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# MeVault Python SDK
|
|
25
|
+
|
|
26
|
+
Request secrets from the [MeVault](https://github.com/thecalebyte/mevault-cli) broker in Python — no passwords, no env-var leakage, no subprocesses.
|
|
27
|
+
|
|
28
|
+
## Requirements
|
|
29
|
+
|
|
30
|
+
- Python 3.9+
|
|
31
|
+
- Windows (the broker communicates over a Windows named pipe)
|
|
32
|
+
- MeVault broker running and vault unlocked
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install mevault
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Or from source:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
cd sdk/python
|
|
44
|
+
pip install -e .
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
### Synchronous
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from mevault import get_secret, get_optional_secret
|
|
53
|
+
from mevault.exceptions import VaultLocked, SecretNotFound, MeVaultUnavailable
|
|
54
|
+
|
|
55
|
+
# Raises SecretNotFound if the secret doesn't exist.
|
|
56
|
+
db_password = get_secret("DB_PASSWORD")
|
|
57
|
+
|
|
58
|
+
# Returns None (or a custom default) if the secret doesn't exist.
|
|
59
|
+
api_key = get_optional_secret("OPTIONAL_API_KEY", default="")
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Asynchronous
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
import asyncio
|
|
66
|
+
from mevault import async_get_secret
|
|
67
|
+
|
|
68
|
+
async def main():
|
|
69
|
+
token = await async_get_secret("AUTH_TOKEN")
|
|
70
|
+
# use token ...
|
|
71
|
+
|
|
72
|
+
asyncio.run(main())
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Error handling
|
|
76
|
+
|
|
77
|
+
| Exception | When raised |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `MeVaultUnavailable` | Broker not running / pipe not found |
|
|
80
|
+
| `VaultLocked` | Vault is locked — user must unlock MeVault |
|
|
81
|
+
| `SecretNotFound` | No secret with that name |
|
|
82
|
+
| `AccessDenied` | Process not permitted to read the secret |
|
|
83
|
+
| `SessionExpired` | Broker session expired — user must re-authenticate |
|
|
84
|
+
| `ProtocolError` | Malformed or oversized broker response |
|
|
85
|
+
|
|
86
|
+
All exceptions derive from `MeVaultError`.
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from mevault.exceptions import MeVaultError
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
value = get_secret("MY_SECRET")
|
|
93
|
+
except MeVaultError as exc:
|
|
94
|
+
# Handle any MeVault error generically.
|
|
95
|
+
print(f"MeVault error: {exc}")
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Security notes
|
|
99
|
+
|
|
100
|
+
- Secret values are **never** logged or printed by the SDK.
|
|
101
|
+
- `SecretValue.__repr__` and `__str__` return `<redacted>` to prevent accidental exposure in logs or tracebacks.
|
|
102
|
+
- The SDK does **not** accept vault passwords and does **not** spawn subprocesses.
|
|
103
|
+
- The SDK does **not** place secrets in environment variables.
|
|
104
|
+
- Each request uses a fresh pipe connection, closed immediately after the response is received.
|
mevault-0.1.0/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# MeVault Python SDK
|
|
2
|
+
|
|
3
|
+
Request secrets from the [MeVault](https://github.com/thecalebyte/mevault-cli) broker in Python — no passwords, no env-var leakage, no subprocesses.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Python 3.9+
|
|
8
|
+
- Windows (the broker communicates over a Windows named pipe)
|
|
9
|
+
- MeVault broker running and vault unlocked
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install mevault
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Or from source:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
cd sdk/python
|
|
21
|
+
pip install -e .
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
### Synchronous
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from mevault import get_secret, get_optional_secret
|
|
30
|
+
from mevault.exceptions import VaultLocked, SecretNotFound, MeVaultUnavailable
|
|
31
|
+
|
|
32
|
+
# Raises SecretNotFound if the secret doesn't exist.
|
|
33
|
+
db_password = get_secret("DB_PASSWORD")
|
|
34
|
+
|
|
35
|
+
# Returns None (or a custom default) if the secret doesn't exist.
|
|
36
|
+
api_key = get_optional_secret("OPTIONAL_API_KEY", default="")
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Asynchronous
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import asyncio
|
|
43
|
+
from mevault import async_get_secret
|
|
44
|
+
|
|
45
|
+
async def main():
|
|
46
|
+
token = await async_get_secret("AUTH_TOKEN")
|
|
47
|
+
# use token ...
|
|
48
|
+
|
|
49
|
+
asyncio.run(main())
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Error handling
|
|
53
|
+
|
|
54
|
+
| Exception | When raised |
|
|
55
|
+
|---|---|
|
|
56
|
+
| `MeVaultUnavailable` | Broker not running / pipe not found |
|
|
57
|
+
| `VaultLocked` | Vault is locked — user must unlock MeVault |
|
|
58
|
+
| `SecretNotFound` | No secret with that name |
|
|
59
|
+
| `AccessDenied` | Process not permitted to read the secret |
|
|
60
|
+
| `SessionExpired` | Broker session expired — user must re-authenticate |
|
|
61
|
+
| `ProtocolError` | Malformed or oversized broker response |
|
|
62
|
+
|
|
63
|
+
All exceptions derive from `MeVaultError`.
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from mevault.exceptions import MeVaultError
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
value = get_secret("MY_SECRET")
|
|
70
|
+
except MeVaultError as exc:
|
|
71
|
+
# Handle any MeVault error generically.
|
|
72
|
+
print(f"MeVault error: {exc}")
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Security notes
|
|
76
|
+
|
|
77
|
+
- Secret values are **never** logged or printed by the SDK.
|
|
78
|
+
- `SecretValue.__repr__` and `__str__` return `<redacted>` to prevent accidental exposure in logs or tracebacks.
|
|
79
|
+
- The SDK does **not** accept vault passwords and does **not** spawn subprocesses.
|
|
80
|
+
- The SDK does **not** place secrets in environment variables.
|
|
81
|
+
- Each request uses a fresh pipe connection, closed immediately after the response is received.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mevault"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "MeVault Python SDK — request secrets from the local MeVault broker"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "Apache-2.0" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
keywords = ["secrets", "security", "mevault", "credentials"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: Apache Software License",
|
|
17
|
+
"Operating System :: Microsoft :: Windows",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.9",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Topic :: Security",
|
|
24
|
+
"Topic :: Software Development :: Libraries",
|
|
25
|
+
]
|
|
26
|
+
dependencies = []
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/thecalebyte/mevault-cli"
|
|
30
|
+
Documentation = "https://github.com/thecalebyte/mevault-cli/blob/main/docs/sdk.md"
|
|
31
|
+
"Bug Tracker" = "https://github.com/thecalebyte/mevault-cli/issues"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/mevault"]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""
|
|
2
|
+
mevault — Python SDK for requesting secrets from the local MeVault broker.
|
|
3
|
+
|
|
4
|
+
Quickstart
|
|
5
|
+
----------
|
|
6
|
+
|
|
7
|
+
Synchronous::
|
|
8
|
+
|
|
9
|
+
from mevault import get_secret, get_optional_secret
|
|
10
|
+
from mevault.exceptions import VaultLocked, SecretNotFound
|
|
11
|
+
|
|
12
|
+
db_password = get_secret("DB_PASSWORD")
|
|
13
|
+
api_key = get_optional_secret("OPTIONAL_API_KEY", default="")
|
|
14
|
+
|
|
15
|
+
Asynchronous::
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
from mevault import async_get_secret
|
|
19
|
+
|
|
20
|
+
async def main():
|
|
21
|
+
token = await async_get_secret("AUTH_TOKEN")
|
|
22
|
+
|
|
23
|
+
asyncio.run(main())
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from .client import async_get_secret, get_optional_secret, get_secret
|
|
27
|
+
from .exceptions import (
|
|
28
|
+
AccessDenied,
|
|
29
|
+
MeVaultError,
|
|
30
|
+
MeVaultUnavailable,
|
|
31
|
+
ProtocolError,
|
|
32
|
+
SecretNotFound,
|
|
33
|
+
SessionExpired,
|
|
34
|
+
VaultLocked,
|
|
35
|
+
)
|
|
36
|
+
from .types import SecretValue
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
# Functions
|
|
40
|
+
"get_secret",
|
|
41
|
+
"get_optional_secret",
|
|
42
|
+
"async_get_secret",
|
|
43
|
+
# Exceptions
|
|
44
|
+
"MeVaultError",
|
|
45
|
+
"MeVaultUnavailable",
|
|
46
|
+
"VaultLocked",
|
|
47
|
+
"SecretNotFound",
|
|
48
|
+
"AccessDenied",
|
|
49
|
+
"ProtocolError",
|
|
50
|
+
"SessionExpired",
|
|
51
|
+
# Types
|
|
52
|
+
"SecretValue",
|
|
53
|
+
]
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MeVault client — synchronous and asyncio interfaces.
|
|
3
|
+
|
|
4
|
+
Design constraints
|
|
5
|
+
------------------
|
|
6
|
+
- Named-pipe transport only (no subprocess, no env-var injection).
|
|
7
|
+
- No vault password accepted or stored.
|
|
8
|
+
- Secret values are NEVER logged; only error codes are logged.
|
|
9
|
+
- Hard 10-second timeout on every operation.
|
|
10
|
+
- Hard 1 MiB cap on response size.
|
|
11
|
+
- One connection per request; connection closed immediately after response.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import sys
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
from .exceptions import (
|
|
21
|
+
AccessDenied,
|
|
22
|
+
MeVaultUnavailable,
|
|
23
|
+
ProtocolError,
|
|
24
|
+
SecretNotFound,
|
|
25
|
+
SessionExpired,
|
|
26
|
+
VaultLocked,
|
|
27
|
+
)
|
|
28
|
+
from .protocol import (
|
|
29
|
+
KNOWN_ERROR_CODES,
|
|
30
|
+
MAX_RESPONSE_BYTES,
|
|
31
|
+
PIPE_NAME,
|
|
32
|
+
PROTOCOL_VERSION,
|
|
33
|
+
BrokerResponse,
|
|
34
|
+
GetSecretRequest,
|
|
35
|
+
)
|
|
36
|
+
from .types import SecretValue
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
# Default timeout for all I/O operations (seconds).
|
|
41
|
+
_TIMEOUT: float = 10.0
|
|
42
|
+
|
|
43
|
+
# Windows error code: pipe server is busy (all instances occupied).
|
|
44
|
+
_ERROR_PIPE_BUSY: int = 231
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Internal helpers
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _map_error(error_code: Optional[str], message: Optional[str]) -> Exception:
|
|
53
|
+
"""Convert a broker error code into the appropriate SDK exception."""
|
|
54
|
+
detail = message or error_code or "unknown error"
|
|
55
|
+
mapping = {
|
|
56
|
+
"vault_locked": VaultLocked,
|
|
57
|
+
"session_expired": SessionExpired,
|
|
58
|
+
"access_denied": AccessDenied,
|
|
59
|
+
"secret_not_found": SecretNotFound,
|
|
60
|
+
}
|
|
61
|
+
exc_class = mapping.get(error_code or "", MeVaultUnavailable) # type: ignore[arg-type]
|
|
62
|
+
# Log only the error code, never the message (which might contain secret names).
|
|
63
|
+
logger.debug("broker returned error code: %s", error_code)
|
|
64
|
+
return exc_class(detail)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _parse_response(raw_bytes: bytes) -> BrokerResponse:
|
|
68
|
+
"""
|
|
69
|
+
Parse a raw response line from the broker.
|
|
70
|
+
|
|
71
|
+
Raises ProtocolError on any malformed or oversized payload.
|
|
72
|
+
"""
|
|
73
|
+
if len(raw_bytes) > MAX_RESPONSE_BYTES:
|
|
74
|
+
raise ProtocolError(
|
|
75
|
+
f"Response exceeded maximum allowed size of {MAX_RESPONSE_BYTES} bytes"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
data = json.loads(raw_bytes.decode("utf-8"))
|
|
80
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
81
|
+
raise ProtocolError(f"Malformed JSON response from broker: {exc}") from exc
|
|
82
|
+
|
|
83
|
+
if not isinstance(data, dict):
|
|
84
|
+
raise ProtocolError("Broker response was not a JSON object")
|
|
85
|
+
|
|
86
|
+
ok = data.get("ok")
|
|
87
|
+
if not isinstance(ok, bool):
|
|
88
|
+
raise ProtocolError("Broker response missing boolean 'ok' field")
|
|
89
|
+
|
|
90
|
+
# Validate protocol version if the broker echoes it.
|
|
91
|
+
version = data.get("version")
|
|
92
|
+
if version is not None and version != PROTOCOL_VERSION:
|
|
93
|
+
raise ProtocolError(
|
|
94
|
+
f"Protocol version mismatch: expected {PROTOCOL_VERSION}, got {version}"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
return BrokerResponse(
|
|
98
|
+
ok=ok,
|
|
99
|
+
value=data.get("value"),
|
|
100
|
+
error=data.get("error"),
|
|
101
|
+
message=data.get("message"),
|
|
102
|
+
version=version,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _build_request(name: str) -> bytes:
|
|
107
|
+
"""Serialise a GetSecretRequest to a newline-terminated JSON byte string."""
|
|
108
|
+
req = GetSecretRequest(name=name)
|
|
109
|
+
return (json.dumps(req.to_dict()) + "\n").encode("utf-8")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# Platform guard — MeVault only runs on Windows
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
if sys.platform != "win32":
|
|
117
|
+
def get_secret(name: str) -> str:
|
|
118
|
+
"""Raise MeVaultUnavailable: MeVault only runs on Windows."""
|
|
119
|
+
raise MeVaultUnavailable("MeVault is only supported on Windows")
|
|
120
|
+
|
|
121
|
+
def get_optional_secret(name: str, default: Optional[str] = None) -> Optional[str]:
|
|
122
|
+
"""Return *default*: MeVault only runs on Windows."""
|
|
123
|
+
return default
|
|
124
|
+
|
|
125
|
+
async def async_get_secret(name: str) -> str:
|
|
126
|
+
"""Raise MeVaultUnavailable: MeVault only runs on Windows."""
|
|
127
|
+
raise MeVaultUnavailable("MeVault is only supported on Windows")
|
|
128
|
+
|
|
129
|
+
else:
|
|
130
|
+
# ------------------------------------------------------------------
|
|
131
|
+
# Windows-only transport
|
|
132
|
+
# ------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
import ctypes
|
|
135
|
+
import ctypes.wintypes
|
|
136
|
+
|
|
137
|
+
def _wait_and_open(pipe_name: str, timeout_ms: int = 10000): # type: ignore[return]
|
|
138
|
+
"""
|
|
139
|
+
Open a Windows named pipe, waiting if all server instances are busy.
|
|
140
|
+
|
|
141
|
+
Uses WaitNamedPipeW from kernel32 so the caller blocks instead of
|
|
142
|
+
busy-looping when the broker is serving another connection.
|
|
143
|
+
|
|
144
|
+
Returns a raw binary file object (unbuffered) on success.
|
|
145
|
+
Raises MeVaultUnavailable if the pipe cannot be opened within *timeout_ms*.
|
|
146
|
+
"""
|
|
147
|
+
deadline_ms = timeout_ms
|
|
148
|
+
while True:
|
|
149
|
+
try:
|
|
150
|
+
# Named pipes opened via open() in binary+unbuffered mode work
|
|
151
|
+
# correctly on Windows for byte-stream pipes.
|
|
152
|
+
return open(pipe_name, "r+b", buffering=0) # noqa: WPS515
|
|
153
|
+
except OSError as exc:
|
|
154
|
+
winerr = getattr(exc, "winerror", None)
|
|
155
|
+
if winerr == _ERROR_PIPE_BUSY:
|
|
156
|
+
# All pipe instances are in use; ask the kernel to wait.
|
|
157
|
+
wait_ms = min(deadline_ms, 1000)
|
|
158
|
+
ok = ctypes.windll.kernel32.WaitNamedPipeW(pipe_name, wait_ms)
|
|
159
|
+
if not ok:
|
|
160
|
+
raise MeVaultUnavailable(
|
|
161
|
+
f"MeVault broker pipe busy after {timeout_ms} ms"
|
|
162
|
+
) from exc
|
|
163
|
+
deadline_ms -= wait_ms
|
|
164
|
+
if deadline_ms <= 0:
|
|
165
|
+
raise MeVaultUnavailable(
|
|
166
|
+
"MeVault broker pipe wait timed out"
|
|
167
|
+
) from exc
|
|
168
|
+
elif exc.errno == 2 or isinstance(exc, FileNotFoundError):
|
|
169
|
+
raise MeVaultUnavailable(
|
|
170
|
+
"MeVault broker is not running (pipe not found)"
|
|
171
|
+
) from exc
|
|
172
|
+
elif isinstance(exc, PermissionError):
|
|
173
|
+
raise MeVaultUnavailable(
|
|
174
|
+
"Permission denied when connecting to MeVault broker"
|
|
175
|
+
) from exc
|
|
176
|
+
else:
|
|
177
|
+
raise MeVaultUnavailable(
|
|
178
|
+
f"Cannot open MeVault pipe: {exc}"
|
|
179
|
+
) from exc
|
|
180
|
+
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
# Synchronous implementation (Windows named pipe via open())
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
def _sync_transact(name: str) -> str:
|
|
186
|
+
"""
|
|
187
|
+
Open the named pipe, send a get_secret request, and return the secret value.
|
|
188
|
+
|
|
189
|
+
The pipe is opened with WaitNamedPipe semantics so the call blocks
|
|
190
|
+
correctly when the broker is momentarily busy serving another client.
|
|
191
|
+
The connection is closed once the response line is read regardless of outcome.
|
|
192
|
+
"""
|
|
193
|
+
request_bytes = _build_request(name)
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
pipe = _wait_and_open(PIPE_NAME, timeout_ms=int(_TIMEOUT * 1000))
|
|
197
|
+
except MeVaultUnavailable:
|
|
198
|
+
raise
|
|
199
|
+
except OSError as exc:
|
|
200
|
+
raise MeVaultUnavailable(
|
|
201
|
+
f"Could not connect to MeVault broker: {exc}"
|
|
202
|
+
) from exc
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
pipe.write(request_bytes)
|
|
206
|
+
|
|
207
|
+
# Read response line by line (capped at MAX_RESPONSE_BYTES + 1).
|
|
208
|
+
response_bytes = b""
|
|
209
|
+
while True:
|
|
210
|
+
chunk = pipe.read(4096)
|
|
211
|
+
if not chunk:
|
|
212
|
+
break
|
|
213
|
+
response_bytes += chunk
|
|
214
|
+
if b"\n" in response_bytes:
|
|
215
|
+
break
|
|
216
|
+
if len(response_bytes) > MAX_RESPONSE_BYTES:
|
|
217
|
+
raise ProtocolError(
|
|
218
|
+
f"Response exceeded maximum allowed size of {MAX_RESPONSE_BYTES} bytes"
|
|
219
|
+
)
|
|
220
|
+
except ProtocolError:
|
|
221
|
+
raise
|
|
222
|
+
except OSError as exc:
|
|
223
|
+
raise MeVaultUnavailable(
|
|
224
|
+
f"I/O error while communicating with MeVault broker: {exc}"
|
|
225
|
+
) from exc
|
|
226
|
+
finally:
|
|
227
|
+
try:
|
|
228
|
+
pipe.close()
|
|
229
|
+
except OSError:
|
|
230
|
+
pass
|
|
231
|
+
|
|
232
|
+
if not response_bytes:
|
|
233
|
+
raise ProtocolError("Broker closed the connection without sending a response")
|
|
234
|
+
|
|
235
|
+
# Parse only the first line.
|
|
236
|
+
line = response_bytes.split(b"\n")[0]
|
|
237
|
+
response = _parse_response(line)
|
|
238
|
+
|
|
239
|
+
if response.ok:
|
|
240
|
+
if response.value is None:
|
|
241
|
+
raise ProtocolError("Broker returned ok=true but no 'value' field")
|
|
242
|
+
# Value is intentionally NOT logged here.
|
|
243
|
+
return response.value
|
|
244
|
+
|
|
245
|
+
raise _map_error(response.error, response.message)
|
|
246
|
+
|
|
247
|
+
# ---------------------------------------------------------------------------
|
|
248
|
+
# Public synchronous API
|
|
249
|
+
# ---------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
def get_secret(name: str) -> str:
|
|
252
|
+
"""
|
|
253
|
+
Retrieve a secret from the MeVault broker synchronously.
|
|
254
|
+
|
|
255
|
+
Parameters
|
|
256
|
+
----------
|
|
257
|
+
name:
|
|
258
|
+
The name of the secret to retrieve.
|
|
259
|
+
|
|
260
|
+
Returns
|
|
261
|
+
-------
|
|
262
|
+
str
|
|
263
|
+
The secret value as a plain string.
|
|
264
|
+
|
|
265
|
+
Raises
|
|
266
|
+
------
|
|
267
|
+
MeVaultUnavailable
|
|
268
|
+
The broker pipe could not be reached.
|
|
269
|
+
VaultLocked
|
|
270
|
+
The vault is locked.
|
|
271
|
+
SecretNotFound
|
|
272
|
+
No secret with that name exists.
|
|
273
|
+
AccessDenied
|
|
274
|
+
The calling process is not permitted to read this secret.
|
|
275
|
+
SessionExpired
|
|
276
|
+
The broker session has expired.
|
|
277
|
+
ProtocolError
|
|
278
|
+
The broker sent a malformed response.
|
|
279
|
+
"""
|
|
280
|
+
return _sync_transact(name)
|
|
281
|
+
|
|
282
|
+
def get_optional_secret(name: str, default: Optional[str] = None) -> Optional[str]:
|
|
283
|
+
"""
|
|
284
|
+
Retrieve a secret, returning *default* if the secret does not exist.
|
|
285
|
+
|
|
286
|
+
All other errors (VaultLocked, AccessDenied, etc.) are still raised.
|
|
287
|
+
|
|
288
|
+
Parameters
|
|
289
|
+
----------
|
|
290
|
+
name:
|
|
291
|
+
The name of the secret to retrieve.
|
|
292
|
+
default:
|
|
293
|
+
Value to return when the secret is not found. Defaults to ``None``.
|
|
294
|
+
|
|
295
|
+
Returns
|
|
296
|
+
-------
|
|
297
|
+
str or None
|
|
298
|
+
The secret value, or *default* if not found.
|
|
299
|
+
"""
|
|
300
|
+
try:
|
|
301
|
+
return get_secret(name)
|
|
302
|
+
except SecretNotFound:
|
|
303
|
+
return default
|
|
304
|
+
|
|
305
|
+
# ---------------------------------------------------------------------------
|
|
306
|
+
# Asynchronous implementation — wraps sync call in thread pool
|
|
307
|
+
# ---------------------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
async def async_get_secret(name: str) -> str:
|
|
310
|
+
"""
|
|
311
|
+
Retrieve a secret from the MeVault broker asynchronously.
|
|
312
|
+
|
|
313
|
+
Runs the synchronous named-pipe call in a thread-pool executor so the
|
|
314
|
+
event loop is never blocked. This is the correct approach for Windows
|
|
315
|
+
named pipes: ``asyncio.open_connection()`` cannot open a UNC pipe path.
|
|
316
|
+
|
|
317
|
+
Parameters
|
|
318
|
+
----------
|
|
319
|
+
name:
|
|
320
|
+
The name of the secret to retrieve.
|
|
321
|
+
|
|
322
|
+
Returns
|
|
323
|
+
-------
|
|
324
|
+
str
|
|
325
|
+
The secret value as a plain string.
|
|
326
|
+
|
|
327
|
+
Raises
|
|
328
|
+
------
|
|
329
|
+
MeVaultUnavailable
|
|
330
|
+
The broker pipe could not be reached.
|
|
331
|
+
VaultLocked
|
|
332
|
+
The vault is locked.
|
|
333
|
+
SecretNotFound
|
|
334
|
+
No secret with that name exists.
|
|
335
|
+
AccessDenied
|
|
336
|
+
The calling process is not permitted to read this secret.
|
|
337
|
+
SessionExpired
|
|
338
|
+
The broker session has expired.
|
|
339
|
+
ProtocolError
|
|
340
|
+
The broker sent a malformed response.
|
|
341
|
+
"""
|
|
342
|
+
loop = asyncio.get_event_loop()
|
|
343
|
+
return await loop.run_in_executor(None, get_secret, name)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MeVault exception hierarchy.
|
|
3
|
+
|
|
4
|
+
All exceptions raised by the MeVault SDK derive from MeVaultError so callers
|
|
5
|
+
can catch the whole family with a single except clause if they prefer.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MeVaultError(Exception):
|
|
10
|
+
"""Base class for all MeVault SDK errors."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MeVaultUnavailable(MeVaultError):
|
|
14
|
+
"""The MeVault broker pipe could not be reached (not running or not found)."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class VaultLocked(MeVaultError):
|
|
18
|
+
"""The vault is locked; the user must unlock MeVault before secrets can be read."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SecretNotFound(MeVaultError):
|
|
22
|
+
"""The requested secret does not exist in the vault."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AccessDenied(MeVaultError):
|
|
26
|
+
"""The calling process is not authorised to read this secret."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ProtocolError(MeVaultError):
|
|
30
|
+
"""The broker returned an unexpected or malformed response."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SessionExpired(MeVaultError):
|
|
34
|
+
"""The broker session has expired; the user must re-authenticate."""
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Wire-protocol constants and data structures for the MeVault named-pipe IPC.
|
|
3
|
+
|
|
4
|
+
Protocol summary
|
|
5
|
+
----------------
|
|
6
|
+
- One JSON line per message, terminated with ``\\n``.
|
|
7
|
+
- One connection per request; the client closes the connection after reading
|
|
8
|
+
the response.
|
|
9
|
+
- The broker may include an optional ``"version"`` field in the response;
|
|
10
|
+
if present it must match PROTOCOL_VERSION.
|
|
11
|
+
|
|
12
|
+
Request (client → broker)::
|
|
13
|
+
|
|
14
|
+
{"op": "get_secret", "name": "<SECRET_NAME>"}
|
|
15
|
+
|
|
16
|
+
Success response (broker → client)::
|
|
17
|
+
|
|
18
|
+
{"ok": true, "value": "<secret-value>"}
|
|
19
|
+
|
|
20
|
+
Error response (broker → client)::
|
|
21
|
+
|
|
22
|
+
{"ok": false, "error": "<error-code>", "message": "<optional detail>"}
|
|
23
|
+
|
|
24
|
+
Known error codes
|
|
25
|
+
-----------------
|
|
26
|
+
vault_locked, session_expired, access_denied, secret_not_found,
|
|
27
|
+
identity_unknown, grant_invalid, internal_error, invalid_request
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from typing import Optional
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Constants
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
PIPE_NAME: str = r"\\.\pipe\mevault-runtime"
|
|
39
|
+
PROTOCOL_VERSION: int = 1
|
|
40
|
+
MAX_RESPONSE_BYTES: int = 1_048_576 # 1 MiB
|
|
41
|
+
|
|
42
|
+
# All error codes the broker may return.
|
|
43
|
+
KNOWN_ERROR_CODES: frozenset = frozenset(
|
|
44
|
+
{
|
|
45
|
+
"vault_locked",
|
|
46
|
+
"session_expired",
|
|
47
|
+
"access_denied",
|
|
48
|
+
"secret_not_found",
|
|
49
|
+
"identity_unknown",
|
|
50
|
+
"grant_invalid",
|
|
51
|
+
"internal_error",
|
|
52
|
+
"invalid_request",
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Request / response dataclasses
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class GetSecretRequest:
|
|
64
|
+
"""Represents a ``get_secret`` request sent to the broker."""
|
|
65
|
+
|
|
66
|
+
name: str
|
|
67
|
+
op: str = field(default="get_secret", init=False)
|
|
68
|
+
|
|
69
|
+
def to_dict(self) -> dict:
|
|
70
|
+
return {"op": self.op, "name": self.name}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class BrokerResponse:
|
|
75
|
+
"""Parsed broker response."""
|
|
76
|
+
|
|
77
|
+
ok: bool
|
|
78
|
+
value: Optional[str] = None # present when ok is True
|
|
79
|
+
error: Optional[str] = None # error code when ok is False
|
|
80
|
+
message: Optional[str] = None # optional human-readable detail
|
|
81
|
+
version: Optional[int] = None # optional protocol version echo
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Value types used by the MeVault SDK.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class SecretValue:
|
|
10
|
+
"""
|
|
11
|
+
Wraps a secret string.
|
|
12
|
+
|
|
13
|
+
The repr and str are deliberately redacted so that logging framework
|
|
14
|
+
calls, exception tracebacks, and interactive sessions never accidentally
|
|
15
|
+
expose the underlying value.
|
|
16
|
+
|
|
17
|
+
Retrieve the raw string only when you genuinely need it:
|
|
18
|
+
|
|
19
|
+
value = secret.get()
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
_value: str
|
|
23
|
+
|
|
24
|
+
def get(self) -> str:
|
|
25
|
+
"""Return the raw secret string."""
|
|
26
|
+
return self._value
|
|
27
|
+
|
|
28
|
+
def __repr__(self) -> str:
|
|
29
|
+
return "SecretValue(<redacted>)"
|
|
30
|
+
|
|
31
|
+
def __str__(self) -> str:
|
|
32
|
+
return "<redacted>"
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for the MeVault Python SDK.
|
|
3
|
+
|
|
4
|
+
All tests mock the named-pipe connection so no live broker is required.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
import unittest
|
|
10
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
11
|
+
|
|
12
|
+
from mevault.client import (
|
|
13
|
+
_build_request,
|
|
14
|
+
_map_error,
|
|
15
|
+
_parse_response,
|
|
16
|
+
_sync_transact,
|
|
17
|
+
_wait_and_open,
|
|
18
|
+
async_get_secret,
|
|
19
|
+
get_optional_secret,
|
|
20
|
+
get_secret,
|
|
21
|
+
)
|
|
22
|
+
from mevault.exceptions import (
|
|
23
|
+
AccessDenied,
|
|
24
|
+
MeVaultUnavailable,
|
|
25
|
+
ProtocolError,
|
|
26
|
+
SecretNotFound,
|
|
27
|
+
SessionExpired,
|
|
28
|
+
VaultLocked,
|
|
29
|
+
)
|
|
30
|
+
from mevault.protocol import MAX_RESPONSE_BYTES
|
|
31
|
+
from mevault.types import SecretValue
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Helper
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _json_line(payload: dict) -> bytes:
|
|
40
|
+
return (json.dumps(payload) + "\n").encode("utf-8")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _make_mock_pipe(response_payload: dict):
|
|
44
|
+
"""
|
|
45
|
+
Return a mock file object that yields a single JSON response line.
|
|
46
|
+
|
|
47
|
+
The mock is wired to behave like an unbuffered binary pipe: the first
|
|
48
|
+
read() returns the full response line (including the trailing newline),
|
|
49
|
+
and subsequent reads return b'' to signal EOF.
|
|
50
|
+
"""
|
|
51
|
+
raw = _json_line(response_payload)
|
|
52
|
+
m = MagicMock()
|
|
53
|
+
m.__enter__ = MagicMock(return_value=m)
|
|
54
|
+
m.__exit__ = MagicMock(return_value=False)
|
|
55
|
+
# First read returns data, second signals EOF so the read-loop terminates.
|
|
56
|
+
m.read.side_effect = [raw, b""]
|
|
57
|
+
m.write.return_value = None
|
|
58
|
+
m.close.return_value = None
|
|
59
|
+
return m
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# SecretValue tests
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class TestSecretValue(unittest.TestCase):
|
|
68
|
+
def test_get_returns_raw_value(self):
|
|
69
|
+
sv = SecretValue(_value="s3cr3t")
|
|
70
|
+
self.assertEqual(sv.get(), "s3cr3t")
|
|
71
|
+
|
|
72
|
+
def test_repr_is_redacted(self):
|
|
73
|
+
sv = SecretValue(_value="s3cr3t")
|
|
74
|
+
self.assertNotIn("s3cr3t", repr(sv))
|
|
75
|
+
self.assertEqual(repr(sv), "SecretValue(<redacted>)")
|
|
76
|
+
|
|
77
|
+
def test_str_is_redacted(self):
|
|
78
|
+
sv = SecretValue(_value="s3cr3t")
|
|
79
|
+
self.assertNotIn("s3cr3t", str(sv))
|
|
80
|
+
self.assertEqual(str(sv), "<redacted>")
|
|
81
|
+
|
|
82
|
+
def test_repr_never_contains_value_for_any_input(self):
|
|
83
|
+
# Empty string is always "in" any string by Python semantics, so skip it.
|
|
84
|
+
for value in ["password", "tok_abc123", "super-secret"]:
|
|
85
|
+
sv = SecretValue(_value=value)
|
|
86
|
+
self.assertNotIn(value, repr(sv), f"repr leaked value: {value!r}")
|
|
87
|
+
|
|
88
|
+
def test_str_never_contains_value_for_any_input(self):
|
|
89
|
+
for value in ["password", "tok_abc123", "super-secret"]:
|
|
90
|
+
sv = SecretValue(_value=value)
|
|
91
|
+
self.assertNotIn(value, str(sv), f"str leaked value: {value!r}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
# _parse_response tests
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class TestParseResponse(unittest.TestCase):
|
|
100
|
+
def test_success_response(self):
|
|
101
|
+
raw = _json_line({"ok": True, "value": "the-secret"})
|
|
102
|
+
resp = _parse_response(raw)
|
|
103
|
+
self.assertTrue(resp.ok)
|
|
104
|
+
self.assertEqual(resp.value, "the-secret")
|
|
105
|
+
|
|
106
|
+
def test_error_response(self):
|
|
107
|
+
raw = _json_line({"ok": False, "error": "vault_locked", "message": "unlock first"})
|
|
108
|
+
resp = _parse_response(raw)
|
|
109
|
+
self.assertFalse(resp.ok)
|
|
110
|
+
self.assertEqual(resp.error, "vault_locked")
|
|
111
|
+
self.assertEqual(resp.message, "unlock first")
|
|
112
|
+
|
|
113
|
+
def test_malformed_json_raises_protocol_error(self):
|
|
114
|
+
with self.assertRaises(ProtocolError):
|
|
115
|
+
_parse_response(b"not-json\n")
|
|
116
|
+
|
|
117
|
+
def test_non_object_json_raises_protocol_error(self):
|
|
118
|
+
with self.assertRaises(ProtocolError):
|
|
119
|
+
_parse_response(b"[1, 2, 3]\n")
|
|
120
|
+
|
|
121
|
+
def test_missing_ok_field_raises_protocol_error(self):
|
|
122
|
+
raw = _json_line({"value": "something"})
|
|
123
|
+
with self.assertRaises(ProtocolError):
|
|
124
|
+
_parse_response(raw)
|
|
125
|
+
|
|
126
|
+
def test_oversized_response_raises_protocol_error(self):
|
|
127
|
+
oversized = b"x" * (MAX_RESPONSE_BYTES + 1)
|
|
128
|
+
with self.assertRaises(ProtocolError):
|
|
129
|
+
_parse_response(oversized)
|
|
130
|
+
|
|
131
|
+
def test_exactly_max_size_is_treated_as_malformed_json(self):
|
|
132
|
+
# Exactly MAX_RESPONSE_BYTES of garbage — not oversized, but not valid JSON.
|
|
133
|
+
payload = b"x" * MAX_RESPONSE_BYTES
|
|
134
|
+
with self.assertRaises(ProtocolError):
|
|
135
|
+
_parse_response(payload)
|
|
136
|
+
|
|
137
|
+
def test_protocol_version_mismatch_raises_protocol_error(self):
|
|
138
|
+
raw = _json_line({"ok": True, "value": "v", "version": 999})
|
|
139
|
+
with self.assertRaises(ProtocolError):
|
|
140
|
+
_parse_response(raw)
|
|
141
|
+
|
|
142
|
+
def test_matching_protocol_version_is_accepted(self):
|
|
143
|
+
from mevault.protocol import PROTOCOL_VERSION
|
|
144
|
+
raw = _json_line({"ok": True, "value": "v", "version": PROTOCOL_VERSION})
|
|
145
|
+
resp = _parse_response(raw)
|
|
146
|
+
self.assertTrue(resp.ok)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
# _map_error tests
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class TestMapError(unittest.TestCase):
|
|
155
|
+
def test_vault_locked(self):
|
|
156
|
+
exc = _map_error("vault_locked", None)
|
|
157
|
+
self.assertIsInstance(exc, VaultLocked)
|
|
158
|
+
|
|
159
|
+
def test_session_expired(self):
|
|
160
|
+
exc = _map_error("session_expired", None)
|
|
161
|
+
self.assertIsInstance(exc, SessionExpired)
|
|
162
|
+
|
|
163
|
+
def test_access_denied(self):
|
|
164
|
+
exc = _map_error("access_denied", None)
|
|
165
|
+
self.assertIsInstance(exc, AccessDenied)
|
|
166
|
+
|
|
167
|
+
def test_secret_not_found(self):
|
|
168
|
+
exc = _map_error("secret_not_found", None)
|
|
169
|
+
self.assertIsInstance(exc, SecretNotFound)
|
|
170
|
+
|
|
171
|
+
def test_unknown_error_code_maps_to_mevault_unavailable(self):
|
|
172
|
+
exc = _map_error("internal_error", "something went wrong")
|
|
173
|
+
self.assertIsInstance(exc, MeVaultUnavailable)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
# get_secret (sync) tests — mocked at _wait_and_open level
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class TestGetSecret(unittest.TestCase):
|
|
182
|
+
def _patch_pipe(self, mock_pipe):
|
|
183
|
+
"""Patch _wait_and_open to return mock_pipe without going through open()."""
|
|
184
|
+
return patch("mevault.client._wait_and_open", return_value=mock_pipe)
|
|
185
|
+
|
|
186
|
+
def test_successful_retrieval(self):
|
|
187
|
+
mock_pipe = _make_mock_pipe({"ok": True, "value": "supersecret"})
|
|
188
|
+
with self._patch_pipe(mock_pipe):
|
|
189
|
+
result = get_secret("MY_SECRET")
|
|
190
|
+
self.assertEqual(result, "supersecret")
|
|
191
|
+
|
|
192
|
+
def test_vault_locked_raises(self):
|
|
193
|
+
mock_pipe = _make_mock_pipe({"ok": False, "error": "vault_locked"})
|
|
194
|
+
with self._patch_pipe(mock_pipe):
|
|
195
|
+
with self.assertRaises(VaultLocked):
|
|
196
|
+
get_secret("MY_SECRET")
|
|
197
|
+
|
|
198
|
+
def test_secret_not_found_raises(self):
|
|
199
|
+
mock_pipe = _make_mock_pipe({"ok": False, "error": "secret_not_found"})
|
|
200
|
+
with self._patch_pipe(mock_pipe):
|
|
201
|
+
with self.assertRaises(SecretNotFound):
|
|
202
|
+
get_secret("MISSING")
|
|
203
|
+
|
|
204
|
+
def test_access_denied_raises(self):
|
|
205
|
+
mock_pipe = _make_mock_pipe({"ok": False, "error": "access_denied"})
|
|
206
|
+
with self._patch_pipe(mock_pipe):
|
|
207
|
+
with self.assertRaises(AccessDenied):
|
|
208
|
+
get_secret("FORBIDDEN")
|
|
209
|
+
|
|
210
|
+
def test_session_expired_raises(self):
|
|
211
|
+
mock_pipe = _make_mock_pipe({"ok": False, "error": "session_expired"})
|
|
212
|
+
with self._patch_pipe(mock_pipe):
|
|
213
|
+
with self.assertRaises(SessionExpired):
|
|
214
|
+
get_secret("ANY")
|
|
215
|
+
|
|
216
|
+
def test_malformed_json_raises_protocol_error(self):
|
|
217
|
+
m = MagicMock()
|
|
218
|
+
m.__enter__ = MagicMock(return_value=m)
|
|
219
|
+
m.__exit__ = MagicMock(return_value=False)
|
|
220
|
+
m.read.side_effect = [b"this is not json\n", b""]
|
|
221
|
+
m.write.return_value = None
|
|
222
|
+
m.close.return_value = None
|
|
223
|
+
with patch("mevault.client._wait_and_open", return_value=m):
|
|
224
|
+
with self.assertRaises(ProtocolError):
|
|
225
|
+
get_secret("ANY")
|
|
226
|
+
|
|
227
|
+
def test_oversized_response_raises_protocol_error(self):
|
|
228
|
+
# Build a response larger than MAX_RESPONSE_BYTES with a newline at the end
|
|
229
|
+
# so the read loop terminates, then _parse_response rejects it.
|
|
230
|
+
oversized = b"x" * (MAX_RESPONSE_BYTES + 1) + b"\n"
|
|
231
|
+
m = MagicMock()
|
|
232
|
+
m.__enter__ = MagicMock(return_value=m)
|
|
233
|
+
m.__exit__ = MagicMock(return_value=False)
|
|
234
|
+
# Return chunks of 4096 until the loop detects overflow.
|
|
235
|
+
chunks = [oversized[i : i + 4096] for i in range(0, len(oversized), 4096)]
|
|
236
|
+
chunks.append(b"")
|
|
237
|
+
m.read.side_effect = chunks
|
|
238
|
+
m.write.return_value = None
|
|
239
|
+
m.close.return_value = None
|
|
240
|
+
with patch("mevault.client._wait_and_open", return_value=m):
|
|
241
|
+
with self.assertRaises(ProtocolError):
|
|
242
|
+
get_secret("ANY")
|
|
243
|
+
|
|
244
|
+
def test_pipe_not_found_raises_mevault_unavailable(self):
|
|
245
|
+
with patch(
|
|
246
|
+
"mevault.client._wait_and_open",
|
|
247
|
+
side_effect=MeVaultUnavailable("pipe missing"),
|
|
248
|
+
):
|
|
249
|
+
with self.assertRaises(MeVaultUnavailable):
|
|
250
|
+
get_secret("ANY")
|
|
251
|
+
|
|
252
|
+
def test_empty_response_raises_protocol_error(self):
|
|
253
|
+
m = MagicMock()
|
|
254
|
+
m.__enter__ = MagicMock(return_value=m)
|
|
255
|
+
m.__exit__ = MagicMock(return_value=False)
|
|
256
|
+
m.read.return_value = b""
|
|
257
|
+
m.write.return_value = None
|
|
258
|
+
m.close.return_value = None
|
|
259
|
+
with patch("mevault.client._wait_and_open", return_value=m):
|
|
260
|
+
with self.assertRaises(ProtocolError):
|
|
261
|
+
get_secret("ANY")
|
|
262
|
+
|
|
263
|
+
def test_ok_true_without_value_raises_protocol_error(self):
|
|
264
|
+
mock_pipe = _make_mock_pipe({"ok": True})
|
|
265
|
+
with self._patch_pipe(mock_pipe):
|
|
266
|
+
with self.assertRaises(ProtocolError):
|
|
267
|
+
get_secret("ANY")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# ---------------------------------------------------------------------------
|
|
271
|
+
# get_optional_secret tests
|
|
272
|
+
# ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class TestGetOptionalSecret(unittest.TestCase):
|
|
276
|
+
def test_returns_value_when_found(self):
|
|
277
|
+
mock_pipe = _make_mock_pipe({"ok": True, "value": "found"})
|
|
278
|
+
with patch("mevault.client._wait_and_open", return_value=mock_pipe):
|
|
279
|
+
result = get_optional_secret("KEY")
|
|
280
|
+
self.assertEqual(result, "found")
|
|
281
|
+
|
|
282
|
+
def test_returns_none_when_not_found(self):
|
|
283
|
+
mock_pipe = _make_mock_pipe({"ok": False, "error": "secret_not_found"})
|
|
284
|
+
with patch("mevault.client._wait_and_open", return_value=mock_pipe):
|
|
285
|
+
result = get_optional_secret("MISSING")
|
|
286
|
+
self.assertIsNone(result)
|
|
287
|
+
|
|
288
|
+
def test_returns_custom_default_when_not_found(self):
|
|
289
|
+
mock_pipe = _make_mock_pipe({"ok": False, "error": "secret_not_found"})
|
|
290
|
+
with patch("mevault.client._wait_and_open", return_value=mock_pipe):
|
|
291
|
+
result = get_optional_secret("MISSING", default="fallback")
|
|
292
|
+
self.assertEqual(result, "fallback")
|
|
293
|
+
|
|
294
|
+
def test_still_raises_vault_locked(self):
|
|
295
|
+
mock_pipe = _make_mock_pipe({"ok": False, "error": "vault_locked"})
|
|
296
|
+
with patch("mevault.client._wait_and_open", return_value=mock_pipe):
|
|
297
|
+
with self.assertRaises(VaultLocked):
|
|
298
|
+
get_optional_secret("ANY")
|
|
299
|
+
|
|
300
|
+
def test_still_raises_access_denied(self):
|
|
301
|
+
mock_pipe = _make_mock_pipe({"ok": False, "error": "access_denied"})
|
|
302
|
+
with patch("mevault.client._wait_and_open", return_value=mock_pipe):
|
|
303
|
+
with self.assertRaises(AccessDenied):
|
|
304
|
+
get_optional_secret("ANY")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# ---------------------------------------------------------------------------
|
|
308
|
+
# async_get_secret tests — mock get_secret (sync) since async uses run_in_executor
|
|
309
|
+
# ---------------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class TestAsyncGetSecret(unittest.IsolatedAsyncioTestCase):
|
|
313
|
+
async def test_successful_retrieval(self):
|
|
314
|
+
with patch("mevault.client.get_secret", return_value="async-secret"):
|
|
315
|
+
result = await async_get_secret("MY_SECRET")
|
|
316
|
+
self.assertEqual(result, "async-secret")
|
|
317
|
+
|
|
318
|
+
async def test_vault_locked_raises(self):
|
|
319
|
+
with patch("mevault.client.get_secret", side_effect=VaultLocked("locked")):
|
|
320
|
+
with self.assertRaises(VaultLocked):
|
|
321
|
+
await async_get_secret("ANY")
|
|
322
|
+
|
|
323
|
+
async def test_secret_not_found_raises(self):
|
|
324
|
+
with patch("mevault.client.get_secret", side_effect=SecretNotFound("MISSING")):
|
|
325
|
+
with self.assertRaises(SecretNotFound):
|
|
326
|
+
await async_get_secret("MISSING")
|
|
327
|
+
|
|
328
|
+
async def test_malformed_json_raises_protocol_error(self):
|
|
329
|
+
with patch("mevault.client.get_secret", side_effect=ProtocolError("bad json")):
|
|
330
|
+
with self.assertRaises(ProtocolError):
|
|
331
|
+
await async_get_secret("ANY")
|
|
332
|
+
|
|
333
|
+
async def test_pipe_not_found_raises_mevault_unavailable(self):
|
|
334
|
+
with patch(
|
|
335
|
+
"mevault.client.get_secret",
|
|
336
|
+
side_effect=MeVaultUnavailable("pipe missing"),
|
|
337
|
+
):
|
|
338
|
+
with self.assertRaises(MeVaultUnavailable):
|
|
339
|
+
await async_get_secret("ANY")
|
|
340
|
+
|
|
341
|
+
async def test_oversized_response_raises_protocol_error(self):
|
|
342
|
+
with patch("mevault.client.get_secret", side_effect=ProtocolError("oversized")):
|
|
343
|
+
with self.assertRaises(ProtocolError):
|
|
344
|
+
await async_get_secret("ANY")
|
|
345
|
+
|
|
346
|
+
async def test_access_denied_raises(self):
|
|
347
|
+
with patch("mevault.client.get_secret", side_effect=AccessDenied("denied")):
|
|
348
|
+
with self.assertRaises(AccessDenied):
|
|
349
|
+
await async_get_secret("ANY")
|
|
350
|
+
|
|
351
|
+
async def test_session_expired_raises(self):
|
|
352
|
+
with patch("mevault.client.get_secret", side_effect=SessionExpired("expired")):
|
|
353
|
+
with self.assertRaises(SessionExpired):
|
|
354
|
+
await async_get_secret("ANY")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
if __name__ == "__main__":
|
|
358
|
+
unittest.main()
|