lockstock-guard 1.0.0__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.
- lockstock_guard/__init__.py +39 -0
- lockstock_guard/cli.py +139 -0
- lockstock_guard/client.py +445 -0
- lockstock_guard/daemon.py +549 -0
- lockstock_guard-1.0.0.dist-info/METADATA +168 -0
- lockstock_guard-1.0.0.dist-info/RECORD +9 -0
- lockstock_guard-1.0.0.dist-info/WHEEL +5 -0
- lockstock_guard-1.0.0.dist-info/entry_points.txt +2 -0
- lockstock_guard-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LockStock Guard - Enterprise Secrets Daemon
|
|
3
|
+
|
|
4
|
+
This package provides the "Secure Enclave in Software" pattern for enterprise
|
|
5
|
+
AI agent deployments. The daemon holds decrypted secrets in memory and serves
|
|
6
|
+
them via Unix socket IPC, ensuring agent applications never have direct access
|
|
7
|
+
to the vault file.
|
|
8
|
+
|
|
9
|
+
Architecture:
|
|
10
|
+
liberty-user: Owns the vault, runs the daemon
|
|
11
|
+
agent-user: Runs application code, connects via socket
|
|
12
|
+
|
|
13
|
+
Components:
|
|
14
|
+
- daemon: The LockStock Guard daemon (lockstock-guard start)
|
|
15
|
+
- client: Client library for agent applications
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
# Start daemon (as liberty-user)
|
|
19
|
+
lockstock-guard start --vault /var/lib/liberty
|
|
20
|
+
|
|
21
|
+
# Agent code (as agent-user)
|
|
22
|
+
from lockstock_guard import client
|
|
23
|
+
secret = client.get("DATABASE_URL")
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
__version__ = "1.0.0"
|
|
27
|
+
|
|
28
|
+
from .client import LibertyClient, get, list_keys, is_available, connect
|
|
29
|
+
from .daemon import LibertyDaemon, SecretCache
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"LibertyClient",
|
|
33
|
+
"LibertyDaemon",
|
|
34
|
+
"SecretCache",
|
|
35
|
+
"get",
|
|
36
|
+
"list_keys",
|
|
37
|
+
"is_available",
|
|
38
|
+
"connect",
|
|
39
|
+
]
|
lockstock_guard/cli.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
LockStock Guard CLI - Enterprise Secrets Daemon
|
|
4
|
+
|
|
5
|
+
This is the command-line interface for the LockStock Guard daemon,
|
|
6
|
+
which implements the "Secure Enclave in Software" pattern.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
lockstock-guard start [--vault PATH] [--socket PATH] [--foreground]
|
|
10
|
+
lockstock-guard stop [--socket PATH]
|
|
11
|
+
lockstock-guard status [--socket PATH]
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import sys
|
|
16
|
+
import os
|
|
17
|
+
|
|
18
|
+
from .daemon import LibertyDaemon
|
|
19
|
+
|
|
20
|
+
# Default paths for enterprise deployment
|
|
21
|
+
DEFAULT_SOCKET_PATH = "/var/run/liberty/liberty.sock"
|
|
22
|
+
DEFAULT_VAULT_PATH = "/var/lib/liberty"
|
|
23
|
+
DEFAULT_PID_FILE = "/var/run/liberty/liberty.pid"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def main():
|
|
27
|
+
"""Main CLI entry point."""
|
|
28
|
+
parser = argparse.ArgumentParser(
|
|
29
|
+
description='LockStock Guard - Enterprise Secrets Daemon',
|
|
30
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
31
|
+
epilog="""
|
|
32
|
+
Architecture:
|
|
33
|
+
This daemon implements the "Secure Enclave in Software" pattern.
|
|
34
|
+
It holds secrets in memory and serves them via Unix socket IPC.
|
|
35
|
+
Agent applications connect to the socket to request secrets.
|
|
36
|
+
|
|
37
|
+
Security:
|
|
38
|
+
- Daemon runs as privileged user (liberty-user) with vault access
|
|
39
|
+
- Agent apps run as unprivileged user with socket access only
|
|
40
|
+
- Secrets never touch agent process memory until explicitly requested
|
|
41
|
+
- Compromised agent cannot dump the vault
|
|
42
|
+
|
|
43
|
+
Examples:
|
|
44
|
+
# Start daemon (as liberty-user or root)
|
|
45
|
+
lockstock-guard start
|
|
46
|
+
|
|
47
|
+
# Start with custom paths
|
|
48
|
+
lockstock-guard start --vault ~/.liberty --socket /tmp/liberty.sock
|
|
49
|
+
|
|
50
|
+
# Start in foreground for debugging
|
|
51
|
+
lockstock-guard start --foreground
|
|
52
|
+
|
|
53
|
+
# Stop daemon
|
|
54
|
+
lockstock-guard stop
|
|
55
|
+
|
|
56
|
+
# Check status
|
|
57
|
+
lockstock-guard status
|
|
58
|
+
|
|
59
|
+
Agent Usage:
|
|
60
|
+
from lockstock_guard import client
|
|
61
|
+
|
|
62
|
+
# Connect to daemon
|
|
63
|
+
secret = client.get("DATABASE_URL", socket_path="/var/run/liberty/liberty.sock")
|
|
64
|
+
"""
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
parser.add_argument(
|
|
68
|
+
"command",
|
|
69
|
+
choices=["start", "stop", "status"],
|
|
70
|
+
help="Command to run"
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--socket",
|
|
74
|
+
default=DEFAULT_SOCKET_PATH,
|
|
75
|
+
help=f"Socket path (default: {DEFAULT_SOCKET_PATH})"
|
|
76
|
+
)
|
|
77
|
+
parser.add_argument(
|
|
78
|
+
"--vault",
|
|
79
|
+
default=DEFAULT_VAULT_PATH,
|
|
80
|
+
help=f"Vault path (default: {DEFAULT_VAULT_PATH})"
|
|
81
|
+
)
|
|
82
|
+
parser.add_argument(
|
|
83
|
+
"--group",
|
|
84
|
+
default="agents",
|
|
85
|
+
help="Socket group for agent access (default: agents)"
|
|
86
|
+
)
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"--foreground", "-f",
|
|
89
|
+
action="store_true",
|
|
90
|
+
help="Run in foreground (don't daemonize)"
|
|
91
|
+
)
|
|
92
|
+
parser.add_argument(
|
|
93
|
+
"--pid",
|
|
94
|
+
default=None,
|
|
95
|
+
help="PID file path (default: alongside socket)"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
args = parser.parse_args()
|
|
99
|
+
|
|
100
|
+
# Expand paths
|
|
101
|
+
socket_path = os.path.expanduser(args.socket)
|
|
102
|
+
vault_path = os.path.expanduser(args.vault)
|
|
103
|
+
|
|
104
|
+
# Derive PID path from socket path if not specified
|
|
105
|
+
pid_file = args.pid
|
|
106
|
+
if pid_file is None:
|
|
107
|
+
from pathlib import Path
|
|
108
|
+
socket_dir = Path(socket_path).parent
|
|
109
|
+
pid_file = str(socket_dir / "liberty.pid")
|
|
110
|
+
|
|
111
|
+
daemon = LibertyDaemon(
|
|
112
|
+
socket_path=socket_path,
|
|
113
|
+
vault_path=vault_path,
|
|
114
|
+
socket_group=args.group,
|
|
115
|
+
pid_file=pid_file,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if args.command == "start":
|
|
119
|
+
print(f"Starting LockStock Guard daemon...")
|
|
120
|
+
print(f" Vault: {vault_path}")
|
|
121
|
+
print(f" Socket: {socket_path}")
|
|
122
|
+
daemon.start(foreground=args.foreground)
|
|
123
|
+
|
|
124
|
+
elif args.command == "stop":
|
|
125
|
+
daemon.stop()
|
|
126
|
+
|
|
127
|
+
elif args.command == "status":
|
|
128
|
+
if daemon._is_running():
|
|
129
|
+
pid = daemon.pid_file.read_text().strip()
|
|
130
|
+
print(f"LockStock Guard is running (PID: {pid})")
|
|
131
|
+
print(f" Socket: {daemon.socket_path}")
|
|
132
|
+
print(f" Vault: {daemon.vault_path}")
|
|
133
|
+
else:
|
|
134
|
+
print("LockStock Guard is not running")
|
|
135
|
+
sys.exit(1)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
if __name__ == "__main__":
|
|
139
|
+
main()
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
LockStock Guard Client - Secure secret retrieval for agent applications.
|
|
4
|
+
|
|
5
|
+
This client connects to the LockStock Guard daemon via Unix socket to retrieve secrets.
|
|
6
|
+
The agent application NEVER has direct access to the vault file.
|
|
7
|
+
|
|
8
|
+
Security Model:
|
|
9
|
+
- Daemon runs as liberty-user, owns the vault
|
|
10
|
+
- Agent runs as app-user, can only access socket
|
|
11
|
+
- Secrets are retrieved on-demand, not bulk-loaded
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
from lockstock_guard import client
|
|
15
|
+
|
|
16
|
+
# Get a single secret
|
|
17
|
+
db_url = client.get("DATABASE_URL")
|
|
18
|
+
|
|
19
|
+
# Get multiple secrets
|
|
20
|
+
secrets = client.get_many(["API_KEY", "DATABASE_URL"])
|
|
21
|
+
|
|
22
|
+
# List available keys
|
|
23
|
+
keys = client.list_keys()
|
|
24
|
+
|
|
25
|
+
# Use context manager for connection pooling
|
|
26
|
+
with client.connect() as conn:
|
|
27
|
+
api_key = conn.get("API_KEY")
|
|
28
|
+
db_url = conn.get("DATABASE_URL")
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
import os
|
|
32
|
+
import socket
|
|
33
|
+
import struct
|
|
34
|
+
import json
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Optional, Dict, List
|
|
37
|
+
from contextlib import contextmanager
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Default socket path (matches daemon)
|
|
41
|
+
DEFAULT_SOCKET_PATH = "/var/run/liberty/liberty.sock"
|
|
42
|
+
|
|
43
|
+
# Protocol constants (must match daemon)
|
|
44
|
+
PROTOCOL_VERSION = 1
|
|
45
|
+
MAX_MESSAGE_SIZE = 64 * 1024
|
|
46
|
+
|
|
47
|
+
# Message types
|
|
48
|
+
MSG_GET_SECRET = 0x01
|
|
49
|
+
MSG_SECRET_RESPONSE = 0x02
|
|
50
|
+
MSG_LIST_KEYS = 0x03
|
|
51
|
+
MSG_KEYS_RESPONSE = 0x04
|
|
52
|
+
MSG_PING = 0x05
|
|
53
|
+
MSG_PONG = 0x06
|
|
54
|
+
MSG_ERROR = 0xFF
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class LibertyError(Exception):
|
|
58
|
+
"""Base exception for Liberty client errors."""
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class DaemonNotRunning(LibertyError):
|
|
63
|
+
"""Daemon is not running or socket not accessible."""
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class SecretNotFound(LibertyError):
|
|
68
|
+
"""Requested secret does not exist."""
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class AccessDenied(LibertyError):
|
|
73
|
+
"""Access to the requested secret was denied."""
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class LibertyClient:
|
|
78
|
+
"""
|
|
79
|
+
Client for communicating with Liberty daemon.
|
|
80
|
+
|
|
81
|
+
Usage:
|
|
82
|
+
client = LibertyClient()
|
|
83
|
+
secret = client.get("DATABASE_URL")
|
|
84
|
+
client.close()
|
|
85
|
+
|
|
86
|
+
Or with context manager:
|
|
87
|
+
with LibertyClient() as client:
|
|
88
|
+
secret = client.get("DATABASE_URL")
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(self, socket_path: str = None):
|
|
92
|
+
"""
|
|
93
|
+
Initialize client.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
socket_path: Path to daemon socket (default: from LIBERTY_SOCKET env or /var/run/liberty/liberty.sock)
|
|
97
|
+
"""
|
|
98
|
+
self.socket_path = socket_path or os.getenv("LIBERTY_SOCKET", DEFAULT_SOCKET_PATH)
|
|
99
|
+
self._socket = None
|
|
100
|
+
|
|
101
|
+
def connect(self):
|
|
102
|
+
"""Connect to daemon."""
|
|
103
|
+
if self._socket is not None:
|
|
104
|
+
return # Already connected
|
|
105
|
+
|
|
106
|
+
socket_path = Path(self.socket_path)
|
|
107
|
+
|
|
108
|
+
if not socket_path.exists():
|
|
109
|
+
raise DaemonNotRunning(f"Socket not found: {self.socket_path}")
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
self._socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
113
|
+
self._socket.connect(str(socket_path))
|
|
114
|
+
except (ConnectionRefusedError, PermissionError) as e:
|
|
115
|
+
self._socket = None
|
|
116
|
+
raise DaemonNotRunning(f"Cannot connect to daemon: {e}")
|
|
117
|
+
|
|
118
|
+
def close(self):
|
|
119
|
+
"""Close connection to daemon."""
|
|
120
|
+
if self._socket:
|
|
121
|
+
try:
|
|
122
|
+
self._socket.close()
|
|
123
|
+
except Exception:
|
|
124
|
+
pass
|
|
125
|
+
self._socket = None
|
|
126
|
+
|
|
127
|
+
def __enter__(self):
|
|
128
|
+
"""Context manager entry."""
|
|
129
|
+
self.connect()
|
|
130
|
+
return self
|
|
131
|
+
|
|
132
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
133
|
+
"""Context manager exit."""
|
|
134
|
+
self.close()
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
def get(self, key: str) -> str:
|
|
138
|
+
"""
|
|
139
|
+
Get a secret value.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
key: Secret key name
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
Secret value
|
|
146
|
+
|
|
147
|
+
Raises:
|
|
148
|
+
SecretNotFound: If key doesn't exist
|
|
149
|
+
AccessDenied: If access is denied
|
|
150
|
+
DaemonNotRunning: If daemon is not available
|
|
151
|
+
"""
|
|
152
|
+
self.connect()
|
|
153
|
+
|
|
154
|
+
# Send request
|
|
155
|
+
payload = key.encode("utf-8")
|
|
156
|
+
message = self._encode_message(MSG_GET_SECRET, payload)
|
|
157
|
+
self._socket.sendall(message)
|
|
158
|
+
|
|
159
|
+
# Read response
|
|
160
|
+
msg_type, response = self._read_response()
|
|
161
|
+
|
|
162
|
+
if msg_type == MSG_SECRET_RESPONSE:
|
|
163
|
+
data = json.loads(response.decode("utf-8"))
|
|
164
|
+
return data["value"]
|
|
165
|
+
|
|
166
|
+
elif msg_type == MSG_ERROR:
|
|
167
|
+
error = json.loads(response.decode("utf-8"))
|
|
168
|
+
error_type = error.get("error")
|
|
169
|
+
|
|
170
|
+
if error_type == "not_found":
|
|
171
|
+
raise SecretNotFound(f"Secret not found: {key}")
|
|
172
|
+
elif error_type == "access_denied":
|
|
173
|
+
raise AccessDenied(f"Access denied: {key}")
|
|
174
|
+
else:
|
|
175
|
+
raise LibertyError(f"Daemon error: {error}")
|
|
176
|
+
|
|
177
|
+
else:
|
|
178
|
+
raise LibertyError(f"Unexpected response type: {msg_type}")
|
|
179
|
+
|
|
180
|
+
def get_many(self, keys: List[str]) -> Dict[str, str]:
|
|
181
|
+
"""
|
|
182
|
+
Get multiple secrets.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
keys: List of secret key names
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
Dict mapping keys to values (missing keys are omitted)
|
|
189
|
+
"""
|
|
190
|
+
result = {}
|
|
191
|
+
for key in keys:
|
|
192
|
+
try:
|
|
193
|
+
result[key] = self.get(key)
|
|
194
|
+
except SecretNotFound:
|
|
195
|
+
pass # Omit missing keys
|
|
196
|
+
return result
|
|
197
|
+
|
|
198
|
+
def list_keys(self) -> List[str]:
|
|
199
|
+
"""
|
|
200
|
+
List available secret keys.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
List of key names
|
|
204
|
+
"""
|
|
205
|
+
self.connect()
|
|
206
|
+
|
|
207
|
+
message = self._encode_message(MSG_LIST_KEYS, b"")
|
|
208
|
+
self._socket.sendall(message)
|
|
209
|
+
|
|
210
|
+
msg_type, response = self._read_response()
|
|
211
|
+
|
|
212
|
+
if msg_type == MSG_KEYS_RESPONSE:
|
|
213
|
+
data = json.loads(response.decode("utf-8"))
|
|
214
|
+
return data.get("keys", [])
|
|
215
|
+
|
|
216
|
+
elif msg_type == MSG_ERROR:
|
|
217
|
+
error = json.loads(response.decode("utf-8"))
|
|
218
|
+
raise LibertyError(f"Daemon error: {error}")
|
|
219
|
+
|
|
220
|
+
else:
|
|
221
|
+
raise LibertyError(f"Unexpected response type: {msg_type}")
|
|
222
|
+
|
|
223
|
+
def ping(self) -> bool:
|
|
224
|
+
"""
|
|
225
|
+
Check if daemon is responding.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
True if daemon responded, False otherwise
|
|
229
|
+
"""
|
|
230
|
+
try:
|
|
231
|
+
self.connect()
|
|
232
|
+
message = self._encode_message(MSG_PING, b"")
|
|
233
|
+
self._socket.sendall(message)
|
|
234
|
+
|
|
235
|
+
msg_type, response = self._read_response()
|
|
236
|
+
return msg_type == MSG_PONG
|
|
237
|
+
|
|
238
|
+
except Exception:
|
|
239
|
+
self.close() # Reset connection on error
|
|
240
|
+
return False
|
|
241
|
+
|
|
242
|
+
def _encode_message(self, msg_type: int, payload: bytes) -> bytes:
|
|
243
|
+
"""Encode message with header."""
|
|
244
|
+
header = struct.pack("!BBL", PROTOCOL_VERSION, msg_type, len(payload))
|
|
245
|
+
return header + payload
|
|
246
|
+
|
|
247
|
+
def _read_response(self) -> tuple:
|
|
248
|
+
"""Read response from daemon. Returns (msg_type, payload)."""
|
|
249
|
+
# Read header
|
|
250
|
+
header = self._recv_exact(6)
|
|
251
|
+
if not header:
|
|
252
|
+
self.close() # Reset connection state
|
|
253
|
+
raise DaemonNotRunning("Connection closed by daemon")
|
|
254
|
+
|
|
255
|
+
version, msg_type, length = struct.unpack("!BBL", header)
|
|
256
|
+
|
|
257
|
+
if version != PROTOCOL_VERSION:
|
|
258
|
+
raise LibertyError(f"Protocol version mismatch: {version}")
|
|
259
|
+
|
|
260
|
+
if length > MAX_MESSAGE_SIZE:
|
|
261
|
+
raise LibertyError(f"Message too large: {length}")
|
|
262
|
+
|
|
263
|
+
# Read payload
|
|
264
|
+
payload = self._recv_exact(length) if length > 0 else b""
|
|
265
|
+
|
|
266
|
+
return msg_type, payload
|
|
267
|
+
|
|
268
|
+
def _recv_exact(self, n: int) -> bytes:
|
|
269
|
+
"""Receive exactly n bytes."""
|
|
270
|
+
data = b""
|
|
271
|
+
while len(data) < n:
|
|
272
|
+
chunk = self._socket.recv(n - len(data))
|
|
273
|
+
if not chunk:
|
|
274
|
+
return None
|
|
275
|
+
data += chunk
|
|
276
|
+
return data
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# Module-level singleton for convenience
|
|
280
|
+
_default_client: Optional[LibertyClient] = None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _get_client() -> LibertyClient:
|
|
284
|
+
"""Get or create default client."""
|
|
285
|
+
global _default_client
|
|
286
|
+
if _default_client is None:
|
|
287
|
+
_default_client = LibertyClient()
|
|
288
|
+
return _default_client
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def get(key: str) -> str:
|
|
292
|
+
"""
|
|
293
|
+
Get a secret from the Liberty daemon.
|
|
294
|
+
|
|
295
|
+
This is the primary API for agent applications.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
key: Secret key name (e.g., "DATABASE_URL", "API_KEY")
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
Secret value
|
|
302
|
+
|
|
303
|
+
Raises:
|
|
304
|
+
SecretNotFound: If key doesn't exist
|
|
305
|
+
AccessDenied: If access is denied
|
|
306
|
+
DaemonNotRunning: If daemon is not available
|
|
307
|
+
|
|
308
|
+
Example:
|
|
309
|
+
import liberty_client
|
|
310
|
+
db_url = liberty_client.get("DATABASE_URL")
|
|
311
|
+
"""
|
|
312
|
+
return _get_client().get(key)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def get_many(keys: List[str]) -> Dict[str, str]:
|
|
316
|
+
"""
|
|
317
|
+
Get multiple secrets from the Liberty daemon.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
keys: List of secret key names
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
Dict mapping keys to values (missing keys are omitted)
|
|
324
|
+
|
|
325
|
+
Example:
|
|
326
|
+
secrets = liberty_client.get_many(["API_KEY", "DATABASE_URL"])
|
|
327
|
+
"""
|
|
328
|
+
return _get_client().get_many(keys)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def list_keys() -> List[str]:
|
|
332
|
+
"""
|
|
333
|
+
List available secret keys.
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
List of key names
|
|
337
|
+
"""
|
|
338
|
+
return _get_client().list_keys()
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def is_available() -> bool:
|
|
342
|
+
"""
|
|
343
|
+
Check if Liberty daemon is available.
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
True if daemon is running and accessible
|
|
347
|
+
"""
|
|
348
|
+
return _get_client().ping()
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
@contextmanager
|
|
352
|
+
def connect(socket_path: str = None):
|
|
353
|
+
"""
|
|
354
|
+
Context manager for explicit connection management.
|
|
355
|
+
|
|
356
|
+
Useful when making many requests to avoid reconnection overhead.
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
socket_path: Optional custom socket path
|
|
360
|
+
|
|
361
|
+
Example:
|
|
362
|
+
with liberty_client.connect() as client:
|
|
363
|
+
api_key = client.get("API_KEY")
|
|
364
|
+
db_url = client.get("DATABASE_URL")
|
|
365
|
+
"""
|
|
366
|
+
client = LibertyClient(socket_path)
|
|
367
|
+
try:
|
|
368
|
+
client.connect()
|
|
369
|
+
yield client
|
|
370
|
+
finally:
|
|
371
|
+
client.close()
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
# Environment variable helper
|
|
375
|
+
def env(key: str, default: str = None) -> Optional[str]:
|
|
376
|
+
"""
|
|
377
|
+
Get a secret, falling back to environment variable.
|
|
378
|
+
|
|
379
|
+
This provides a migration path from env-based config to Liberty.
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
key: Secret/env var name
|
|
383
|
+
default: Default value if neither source has the key
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
Secret value, env value, or default
|
|
387
|
+
|
|
388
|
+
Example:
|
|
389
|
+
# Works with Liberty daemon or falls back to $DATABASE_URL
|
|
390
|
+
db_url = liberty_client.env("DATABASE_URL")
|
|
391
|
+
"""
|
|
392
|
+
try:
|
|
393
|
+
if is_available():
|
|
394
|
+
return get(key)
|
|
395
|
+
except (SecretNotFound, AccessDenied):
|
|
396
|
+
pass
|
|
397
|
+
|
|
398
|
+
return os.getenv(key, default)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
if __name__ == "__main__":
|
|
402
|
+
# Simple CLI for testing
|
|
403
|
+
import sys
|
|
404
|
+
|
|
405
|
+
if len(sys.argv) < 2:
|
|
406
|
+
print("Usage: liberty_client.py <command> [args]")
|
|
407
|
+
print("Commands:")
|
|
408
|
+
print(" get <key> - Get a secret")
|
|
409
|
+
print(" list - List keys")
|
|
410
|
+
print(" ping - Check daemon")
|
|
411
|
+
sys.exit(1)
|
|
412
|
+
|
|
413
|
+
cmd = sys.argv[1]
|
|
414
|
+
|
|
415
|
+
try:
|
|
416
|
+
if cmd == "get" and len(sys.argv) >= 3:
|
|
417
|
+
key = sys.argv[2]
|
|
418
|
+
value = get(key)
|
|
419
|
+
print(value)
|
|
420
|
+
|
|
421
|
+
elif cmd == "list":
|
|
422
|
+
keys = list_keys()
|
|
423
|
+
for k in keys:
|
|
424
|
+
print(k)
|
|
425
|
+
|
|
426
|
+
elif cmd == "ping":
|
|
427
|
+
if is_available():
|
|
428
|
+
print("Daemon is running")
|
|
429
|
+
else:
|
|
430
|
+
print("Daemon is not available")
|
|
431
|
+
sys.exit(1)
|
|
432
|
+
|
|
433
|
+
else:
|
|
434
|
+
print(f"Unknown command: {cmd}")
|
|
435
|
+
sys.exit(1)
|
|
436
|
+
|
|
437
|
+
except DaemonNotRunning as e:
|
|
438
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
439
|
+
sys.exit(1)
|
|
440
|
+
except SecretNotFound as e:
|
|
441
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
442
|
+
sys.exit(1)
|
|
443
|
+
except AccessDenied as e:
|
|
444
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
445
|
+
sys.exit(1)
|
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
LockStock Guard Daemon - Secure Enclave in Software
|
|
4
|
+
|
|
5
|
+
This daemon holds decrypted secrets in memory and serves them via Unix socket.
|
|
6
|
+
Agent applications NEVER have direct access to the vault file.
|
|
7
|
+
|
|
8
|
+
Security Architecture:
|
|
9
|
+
liberty-user: Owns the vault, runs the daemon
|
|
10
|
+
agent-user: Runs application code, connects via socket
|
|
11
|
+
|
|
12
|
+
Filesystem Layout:
|
|
13
|
+
/var/lib/liberty/secrets.enc (liberty-user:liberty-user, mode 600)
|
|
14
|
+
/var/run/liberty/liberty.sock (liberty-user:agents, mode 660)
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
# Start daemon (as liberty-user)
|
|
18
|
+
lockstock-guard start
|
|
19
|
+
|
|
20
|
+
# Agent code (as agent-user)
|
|
21
|
+
from lockstock_guard import client
|
|
22
|
+
secret = client.get("DATABASE_URL")
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
import json
|
|
28
|
+
import socket
|
|
29
|
+
import signal
|
|
30
|
+
import struct
|
|
31
|
+
import threading
|
|
32
|
+
import logging
|
|
33
|
+
import hashlib
|
|
34
|
+
import grp
|
|
35
|
+
import pwd
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from datetime import datetime, timezone
|
|
38
|
+
from typing import Dict, Optional, Any
|
|
39
|
+
|
|
40
|
+
# Import vault from main liberty module
|
|
41
|
+
try:
|
|
42
|
+
from liberty import SecretVault, HardwareFingerprint
|
|
43
|
+
except ImportError:
|
|
44
|
+
# Fallback for standalone testing
|
|
45
|
+
print("Warning: liberty module not found, using mock vault")
|
|
46
|
+
SecretVault = None
|
|
47
|
+
HardwareFingerprint = None
|
|
48
|
+
|
|
49
|
+
logging.basicConfig(
|
|
50
|
+
level=logging.INFO,
|
|
51
|
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
52
|
+
)
|
|
53
|
+
logger = logging.getLogger("lockstock-guard")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Default paths
|
|
57
|
+
DEFAULT_SOCKET_PATH = "/var/run/liberty/liberty.sock"
|
|
58
|
+
DEFAULT_VAULT_PATH = "/var/lib/liberty"
|
|
59
|
+
DEFAULT_PID_FILE = "/var/run/liberty/liberty.pid"
|
|
60
|
+
|
|
61
|
+
# Protocol constants
|
|
62
|
+
PROTOCOL_VERSION = 1
|
|
63
|
+
MAX_MESSAGE_SIZE = 64 * 1024 # 64KB max message
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class DaemonProtocol:
|
|
67
|
+
"""Wire protocol for daemon communication."""
|
|
68
|
+
|
|
69
|
+
# Message types
|
|
70
|
+
MSG_GET_SECRET = 0x01
|
|
71
|
+
MSG_SECRET_RESPONSE = 0x02
|
|
72
|
+
MSG_LIST_KEYS = 0x03
|
|
73
|
+
MSG_KEYS_RESPONSE = 0x04
|
|
74
|
+
MSG_PING = 0x05
|
|
75
|
+
MSG_PONG = 0x06
|
|
76
|
+
MSG_ERROR = 0xFF
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def encode_message(msg_type: int, payload: bytes) -> bytes:
|
|
80
|
+
"""Encode message with length prefix and type."""
|
|
81
|
+
# Format: [version:1][type:1][length:4][payload:N]
|
|
82
|
+
header = struct.pack("!BBL", PROTOCOL_VERSION, msg_type, len(payload))
|
|
83
|
+
return header + payload
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def decode_header(data: bytes) -> tuple:
|
|
87
|
+
"""Decode message header. Returns (version, msg_type, length)."""
|
|
88
|
+
if len(data) < 6:
|
|
89
|
+
raise ValueError("Invalid header: too short")
|
|
90
|
+
version, msg_type, length = struct.unpack("!BBL", data[:6])
|
|
91
|
+
if version != PROTOCOL_VERSION:
|
|
92
|
+
raise ValueError(f"Unsupported protocol version: {version}")
|
|
93
|
+
if length > MAX_MESSAGE_SIZE:
|
|
94
|
+
raise ValueError(f"Message too large: {length}")
|
|
95
|
+
return version, msg_type, length
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class SecretCache:
|
|
99
|
+
"""
|
|
100
|
+
In-memory secret cache with access control.
|
|
101
|
+
|
|
102
|
+
Secrets are decrypted once at daemon startup and held in memory.
|
|
103
|
+
Never written to disk in plaintext.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(self):
|
|
107
|
+
self._secrets: Dict[str, str] = {}
|
|
108
|
+
self._access_log: list = []
|
|
109
|
+
self._lock = threading.RLock()
|
|
110
|
+
|
|
111
|
+
def load_from_vault(self, vault: "SecretVault") -> int:
|
|
112
|
+
"""Load all secrets from vault into memory."""
|
|
113
|
+
with self._lock:
|
|
114
|
+
try:
|
|
115
|
+
raw_secrets = vault._load_secrets()
|
|
116
|
+
# Extract just the values (vault stores metadata too)
|
|
117
|
+
self._secrets = {}
|
|
118
|
+
for key, data in raw_secrets.items():
|
|
119
|
+
if isinstance(data, dict):
|
|
120
|
+
self._secrets[key] = data.get('value', str(data))
|
|
121
|
+
else:
|
|
122
|
+
self._secrets[key] = data
|
|
123
|
+
logger.info(f"Loaded {len(self._secrets)} secrets into cache")
|
|
124
|
+
return len(self._secrets)
|
|
125
|
+
except Exception as e:
|
|
126
|
+
logger.error(f"Failed to load vault: {e}")
|
|
127
|
+
return 0
|
|
128
|
+
|
|
129
|
+
def get(self, key: str, client_info: dict = None) -> Optional[str]:
|
|
130
|
+
"""Get a secret, logging access."""
|
|
131
|
+
with self._lock:
|
|
132
|
+
value = self._secrets.get(key)
|
|
133
|
+
self._log_access(key, client_info, found=value is not None)
|
|
134
|
+
return value
|
|
135
|
+
|
|
136
|
+
def list_keys(self) -> list:
|
|
137
|
+
"""List all secret keys (not values)."""
|
|
138
|
+
with self._lock:
|
|
139
|
+
return list(self._secrets.keys())
|
|
140
|
+
|
|
141
|
+
def _log_access(self, key: str, client_info: dict, found: bool):
|
|
142
|
+
"""Log secret access for audit."""
|
|
143
|
+
entry = {
|
|
144
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
145
|
+
"key": key,
|
|
146
|
+
"found": found,
|
|
147
|
+
"client": client_info or {},
|
|
148
|
+
}
|
|
149
|
+
self._access_log.append(entry)
|
|
150
|
+
# Keep last 1000 entries
|
|
151
|
+
if len(self._access_log) > 1000:
|
|
152
|
+
self._access_log = self._access_log[-1000:]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class ClientHandler(threading.Thread):
|
|
156
|
+
"""Handle a single client connection."""
|
|
157
|
+
|
|
158
|
+
def __init__(self, conn: socket.socket, addr, cache: SecretCache, allowed_keys: set = None):
|
|
159
|
+
super().__init__(daemon=True)
|
|
160
|
+
self.conn = conn
|
|
161
|
+
self.addr = addr
|
|
162
|
+
self.cache = cache
|
|
163
|
+
self.allowed_keys = allowed_keys # If set, restrict to these keys
|
|
164
|
+
self._running = True
|
|
165
|
+
|
|
166
|
+
def run(self):
|
|
167
|
+
"""Handle client requests."""
|
|
168
|
+
try:
|
|
169
|
+
# Get peer credentials (Unix socket only)
|
|
170
|
+
client_info = self._get_peer_credentials()
|
|
171
|
+
logger.info(f"Client connected: {client_info}")
|
|
172
|
+
|
|
173
|
+
while self._running:
|
|
174
|
+
# Read header
|
|
175
|
+
header = self._recv_exact(6)
|
|
176
|
+
if not header:
|
|
177
|
+
break
|
|
178
|
+
|
|
179
|
+
version, msg_type, length = DaemonProtocol.decode_header(header)
|
|
180
|
+
|
|
181
|
+
# Read payload
|
|
182
|
+
payload = self._recv_exact(length) if length > 0 else b""
|
|
183
|
+
|
|
184
|
+
# Handle message
|
|
185
|
+
response = self._handle_message(msg_type, payload, client_info)
|
|
186
|
+
self.conn.sendall(response)
|
|
187
|
+
|
|
188
|
+
except Exception as e:
|
|
189
|
+
logger.error(f"Client handler error: {e}")
|
|
190
|
+
finally:
|
|
191
|
+
self.conn.close()
|
|
192
|
+
logger.info(f"Client disconnected")
|
|
193
|
+
|
|
194
|
+
def _recv_exact(self, n: int) -> bytes:
|
|
195
|
+
"""Receive exactly n bytes."""
|
|
196
|
+
data = b""
|
|
197
|
+
while len(data) < n:
|
|
198
|
+
chunk = self.conn.recv(n - len(data))
|
|
199
|
+
if not chunk:
|
|
200
|
+
return None
|
|
201
|
+
data += chunk
|
|
202
|
+
return data
|
|
203
|
+
|
|
204
|
+
def _get_peer_credentials(self) -> dict:
|
|
205
|
+
"""Get peer credentials from Unix socket."""
|
|
206
|
+
try:
|
|
207
|
+
creds = self.conn.getsockopt(
|
|
208
|
+
socket.SOL_SOCKET,
|
|
209
|
+
socket.SO_PEERCRED,
|
|
210
|
+
struct.calcsize("3i")
|
|
211
|
+
)
|
|
212
|
+
pid, uid, gid = struct.unpack("3i", creds)
|
|
213
|
+
return {
|
|
214
|
+
"pid": pid,
|
|
215
|
+
"uid": uid,
|
|
216
|
+
"gid": gid,
|
|
217
|
+
"user": pwd.getpwuid(uid).pw_name if uid >= 0 else "unknown",
|
|
218
|
+
}
|
|
219
|
+
except Exception:
|
|
220
|
+
return {"pid": 0, "uid": -1, "gid": -1, "user": "unknown"}
|
|
221
|
+
|
|
222
|
+
def _handle_message(self, msg_type: int, payload: bytes, client_info: dict) -> bytes:
|
|
223
|
+
"""Handle a single message."""
|
|
224
|
+
try:
|
|
225
|
+
if msg_type == DaemonProtocol.MSG_PING:
|
|
226
|
+
return DaemonProtocol.encode_message(DaemonProtocol.MSG_PONG, b"pong")
|
|
227
|
+
|
|
228
|
+
elif msg_type == DaemonProtocol.MSG_GET_SECRET:
|
|
229
|
+
key = payload.decode("utf-8")
|
|
230
|
+
|
|
231
|
+
# Check access control
|
|
232
|
+
if self.allowed_keys and key not in self.allowed_keys:
|
|
233
|
+
error = json.dumps({"error": "access_denied", "key": key})
|
|
234
|
+
return DaemonProtocol.encode_message(
|
|
235
|
+
DaemonProtocol.MSG_ERROR,
|
|
236
|
+
error.encode("utf-8")
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
value = self.cache.get(key, client_info)
|
|
240
|
+
if value is None:
|
|
241
|
+
error = json.dumps({"error": "not_found", "key": key})
|
|
242
|
+
return DaemonProtocol.encode_message(
|
|
243
|
+
DaemonProtocol.MSG_ERROR,
|
|
244
|
+
error.encode("utf-8")
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
response = json.dumps({"key": key, "value": value})
|
|
248
|
+
return DaemonProtocol.encode_message(
|
|
249
|
+
DaemonProtocol.MSG_SECRET_RESPONSE,
|
|
250
|
+
response.encode("utf-8")
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
elif msg_type == DaemonProtocol.MSG_LIST_KEYS:
|
|
254
|
+
keys = self.cache.list_keys()
|
|
255
|
+
# Filter by allowed keys if restricted
|
|
256
|
+
if self.allowed_keys:
|
|
257
|
+
keys = [k for k in keys if k in self.allowed_keys]
|
|
258
|
+
response = json.dumps({"keys": keys})
|
|
259
|
+
return DaemonProtocol.encode_message(
|
|
260
|
+
DaemonProtocol.MSG_KEYS_RESPONSE,
|
|
261
|
+
response.encode("utf-8")
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
else:
|
|
265
|
+
error = json.dumps({"error": "unknown_message_type", "type": msg_type})
|
|
266
|
+
return DaemonProtocol.encode_message(
|
|
267
|
+
DaemonProtocol.MSG_ERROR,
|
|
268
|
+
error.encode("utf-8")
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
except Exception as e:
|
|
272
|
+
error = json.dumps({"error": "internal_error", "message": str(e)})
|
|
273
|
+
return DaemonProtocol.encode_message(
|
|
274
|
+
DaemonProtocol.MSG_ERROR,
|
|
275
|
+
error.encode("utf-8")
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class LibertyDaemon:
|
|
280
|
+
"""
|
|
281
|
+
Liberty Daemon - holds secrets in memory, serves via Unix socket.
|
|
282
|
+
|
|
283
|
+
This implements the "Secure Enclave in Software" pattern:
|
|
284
|
+
- Daemon runs as privileged user with vault access
|
|
285
|
+
- Agent apps run as unprivileged user with socket access only
|
|
286
|
+
- Secrets never touch agent process memory until explicitly requested
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
def __init__(
|
|
290
|
+
self,
|
|
291
|
+
socket_path: str = DEFAULT_SOCKET_PATH,
|
|
292
|
+
vault_path: str = DEFAULT_VAULT_PATH,
|
|
293
|
+
pid_file: str = DEFAULT_PID_FILE,
|
|
294
|
+
socket_group: str = "agents",
|
|
295
|
+
):
|
|
296
|
+
self.socket_path = Path(socket_path)
|
|
297
|
+
self.vault_path = Path(vault_path)
|
|
298
|
+
self.pid_file = Path(pid_file)
|
|
299
|
+
self.socket_group = socket_group
|
|
300
|
+
|
|
301
|
+
self.cache = SecretCache()
|
|
302
|
+
self.server_socket = None
|
|
303
|
+
self._running = False
|
|
304
|
+
self._threads = []
|
|
305
|
+
|
|
306
|
+
def start(self, foreground: bool = False):
|
|
307
|
+
"""Start the daemon."""
|
|
308
|
+
# Check if already running
|
|
309
|
+
if self._is_running():
|
|
310
|
+
logger.error("Daemon already running")
|
|
311
|
+
return False
|
|
312
|
+
|
|
313
|
+
# Load secrets into cache
|
|
314
|
+
if not self._load_vault():
|
|
315
|
+
logger.error("Failed to load vault")
|
|
316
|
+
return False
|
|
317
|
+
|
|
318
|
+
# Create socket directory
|
|
319
|
+
self.socket_path.parent.mkdir(parents=True, exist_ok=True)
|
|
320
|
+
|
|
321
|
+
# Remove stale socket
|
|
322
|
+
if self.socket_path.exists():
|
|
323
|
+
self.socket_path.unlink()
|
|
324
|
+
|
|
325
|
+
# Create Unix socket
|
|
326
|
+
self.server_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
327
|
+
self.server_socket.bind(str(self.socket_path))
|
|
328
|
+
|
|
329
|
+
# Set socket permissions: owner + group can connect
|
|
330
|
+
os.chmod(self.socket_path, 0o660)
|
|
331
|
+
|
|
332
|
+
# Set group ownership if specified
|
|
333
|
+
try:
|
|
334
|
+
gid = grp.getgrnam(self.socket_group).gr_gid
|
|
335
|
+
os.chown(self.socket_path, -1, gid)
|
|
336
|
+
except KeyError:
|
|
337
|
+
logger.warning(f"Group '{self.socket_group}' not found, using default")
|
|
338
|
+
|
|
339
|
+
self.server_socket.listen(10)
|
|
340
|
+
logger.info(f"Listening on {self.socket_path}")
|
|
341
|
+
|
|
342
|
+
# Write PID file
|
|
343
|
+
self.pid_file.parent.mkdir(parents=True, exist_ok=True)
|
|
344
|
+
self.pid_file.write_text(str(os.getpid()))
|
|
345
|
+
|
|
346
|
+
# Setup signal handlers (only works in main thread)
|
|
347
|
+
try:
|
|
348
|
+
signal.signal(signal.SIGTERM, self._handle_signal)
|
|
349
|
+
signal.signal(signal.SIGINT, self._handle_signal)
|
|
350
|
+
except ValueError:
|
|
351
|
+
# Not in main thread, skip signal handling
|
|
352
|
+
pass
|
|
353
|
+
|
|
354
|
+
# Daemonize if not foreground
|
|
355
|
+
if not foreground:
|
|
356
|
+
self._daemonize()
|
|
357
|
+
|
|
358
|
+
# Accept connections
|
|
359
|
+
self._running = True
|
|
360
|
+
try:
|
|
361
|
+
while self._running:
|
|
362
|
+
try:
|
|
363
|
+
self.server_socket.settimeout(1.0)
|
|
364
|
+
conn, addr = self.server_socket.accept()
|
|
365
|
+
handler = ClientHandler(conn, addr, self.cache)
|
|
366
|
+
handler.start()
|
|
367
|
+
self._threads.append(handler)
|
|
368
|
+
except socket.timeout:
|
|
369
|
+
continue
|
|
370
|
+
finally:
|
|
371
|
+
self._cleanup()
|
|
372
|
+
|
|
373
|
+
return True
|
|
374
|
+
|
|
375
|
+
def stop(self):
|
|
376
|
+
"""Stop the daemon."""
|
|
377
|
+
if not self._is_running():
|
|
378
|
+
logger.info("Daemon not running")
|
|
379
|
+
return
|
|
380
|
+
|
|
381
|
+
# Read PID and send SIGTERM
|
|
382
|
+
try:
|
|
383
|
+
pid = int(self.pid_file.read_text().strip())
|
|
384
|
+
os.kill(pid, signal.SIGTERM)
|
|
385
|
+
logger.info(f"Sent SIGTERM to PID {pid}")
|
|
386
|
+
except (FileNotFoundError, ValueError, ProcessLookupError) as e:
|
|
387
|
+
logger.error(f"Failed to stop daemon: {e}")
|
|
388
|
+
|
|
389
|
+
def _load_vault(self) -> bool:
|
|
390
|
+
"""Load vault into memory cache."""
|
|
391
|
+
if SecretVault is None:
|
|
392
|
+
logger.error("SecretVault not available")
|
|
393
|
+
return False
|
|
394
|
+
|
|
395
|
+
vault = SecretVault(str(self.vault_path))
|
|
396
|
+
|
|
397
|
+
if not vault.secrets_file.exists():
|
|
398
|
+
logger.error(f"Vault not found at {self.vault_path}")
|
|
399
|
+
return False
|
|
400
|
+
|
|
401
|
+
count = self.cache.load_from_vault(vault)
|
|
402
|
+
|
|
403
|
+
if count == 0:
|
|
404
|
+
logger.warning("Vault is empty or failed to decrypt")
|
|
405
|
+
|
|
406
|
+
return True
|
|
407
|
+
|
|
408
|
+
def _is_running(self) -> bool:
|
|
409
|
+
"""Check if daemon is already running."""
|
|
410
|
+
if not self.pid_file.exists():
|
|
411
|
+
return False
|
|
412
|
+
|
|
413
|
+
try:
|
|
414
|
+
pid = int(self.pid_file.read_text().strip())
|
|
415
|
+
# Check if process exists
|
|
416
|
+
os.kill(pid, 0)
|
|
417
|
+
return True
|
|
418
|
+
except (ValueError, ProcessLookupError):
|
|
419
|
+
# Stale PID file
|
|
420
|
+
self.pid_file.unlink(missing_ok=True)
|
|
421
|
+
return False
|
|
422
|
+
|
|
423
|
+
def _daemonize(self):
|
|
424
|
+
"""Fork into background daemon."""
|
|
425
|
+
# First fork
|
|
426
|
+
if os.fork() > 0:
|
|
427
|
+
sys.exit(0)
|
|
428
|
+
|
|
429
|
+
os.setsid()
|
|
430
|
+
|
|
431
|
+
# Second fork
|
|
432
|
+
if os.fork() > 0:
|
|
433
|
+
sys.exit(0)
|
|
434
|
+
|
|
435
|
+
# Redirect stdio
|
|
436
|
+
sys.stdin = open(os.devnull, 'r')
|
|
437
|
+
sys.stdout = open(os.devnull, 'w')
|
|
438
|
+
sys.stderr = open(os.devnull, 'w')
|
|
439
|
+
|
|
440
|
+
def _handle_signal(self, signum, frame):
|
|
441
|
+
"""Handle shutdown signals."""
|
|
442
|
+
logger.info(f"Received signal {signum}, shutting down")
|
|
443
|
+
self._running = False
|
|
444
|
+
|
|
445
|
+
def _cleanup(self):
|
|
446
|
+
"""Clean up resources."""
|
|
447
|
+
if self.server_socket:
|
|
448
|
+
self.server_socket.close()
|
|
449
|
+
|
|
450
|
+
if self.socket_path.exists():
|
|
451
|
+
self.socket_path.unlink()
|
|
452
|
+
|
|
453
|
+
if self.pid_file.exists():
|
|
454
|
+
self.pid_file.unlink()
|
|
455
|
+
|
|
456
|
+
# Wait for handlers to finish
|
|
457
|
+
for thread in self._threads:
|
|
458
|
+
thread.join(timeout=1.0)
|
|
459
|
+
|
|
460
|
+
logger.info("Daemon stopped")
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def main():
|
|
464
|
+
"""CLI entry point."""
|
|
465
|
+
import argparse
|
|
466
|
+
|
|
467
|
+
parser = argparse.ArgumentParser(
|
|
468
|
+
description="Liberty Daemon - Secure secret server",
|
|
469
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
470
|
+
epilog="""
|
|
471
|
+
Examples:
|
|
472
|
+
# Start daemon (as liberty-user)
|
|
473
|
+
liberty-daemon start
|
|
474
|
+
|
|
475
|
+
# Start in foreground for debugging
|
|
476
|
+
liberty-daemon start --foreground
|
|
477
|
+
|
|
478
|
+
# Stop daemon
|
|
479
|
+
liberty-daemon stop
|
|
480
|
+
|
|
481
|
+
# Check status
|
|
482
|
+
liberty-daemon status
|
|
483
|
+
"""
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
parser.add_argument(
|
|
487
|
+
"command",
|
|
488
|
+
choices=["start", "stop", "status"],
|
|
489
|
+
help="Command to run"
|
|
490
|
+
)
|
|
491
|
+
parser.add_argument(
|
|
492
|
+
"--socket",
|
|
493
|
+
default=DEFAULT_SOCKET_PATH,
|
|
494
|
+
help=f"Socket path (default: {DEFAULT_SOCKET_PATH})"
|
|
495
|
+
)
|
|
496
|
+
parser.add_argument(
|
|
497
|
+
"--vault",
|
|
498
|
+
default=DEFAULT_VAULT_PATH,
|
|
499
|
+
help=f"Vault path (default: {DEFAULT_VAULT_PATH})"
|
|
500
|
+
)
|
|
501
|
+
parser.add_argument(
|
|
502
|
+
"--group",
|
|
503
|
+
default="agents",
|
|
504
|
+
help="Socket group for agent access (default: agents)"
|
|
505
|
+
)
|
|
506
|
+
parser.add_argument(
|
|
507
|
+
"--foreground", "-f",
|
|
508
|
+
action="store_true",
|
|
509
|
+
help="Run in foreground (don't daemonize)"
|
|
510
|
+
)
|
|
511
|
+
parser.add_argument(
|
|
512
|
+
"--pid",
|
|
513
|
+
default=None,
|
|
514
|
+
help="PID file path (default: alongside socket)"
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
args = parser.parse_args()
|
|
518
|
+
|
|
519
|
+
# Derive PID path from socket path if not specified
|
|
520
|
+
pid_file = args.pid
|
|
521
|
+
if pid_file is None:
|
|
522
|
+
socket_dir = Path(args.socket).parent
|
|
523
|
+
pid_file = str(socket_dir / "liberty.pid")
|
|
524
|
+
|
|
525
|
+
daemon = LibertyDaemon(
|
|
526
|
+
socket_path=args.socket,
|
|
527
|
+
vault_path=args.vault,
|
|
528
|
+
socket_group=args.group,
|
|
529
|
+
pid_file=pid_file,
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
if args.command == "start":
|
|
533
|
+
daemon.start(foreground=args.foreground)
|
|
534
|
+
|
|
535
|
+
elif args.command == "stop":
|
|
536
|
+
daemon.stop()
|
|
537
|
+
|
|
538
|
+
elif args.command == "status":
|
|
539
|
+
if daemon._is_running():
|
|
540
|
+
pid = daemon.pid_file.read_text().strip()
|
|
541
|
+
print(f"Liberty daemon is running (PID: {pid})")
|
|
542
|
+
print(f"Socket: {daemon.socket_path}")
|
|
543
|
+
else:
|
|
544
|
+
print("Liberty daemon is not running")
|
|
545
|
+
sys.exit(1)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
if __name__ == "__main__":
|
|
549
|
+
main()
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lockstock-guard
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Enterprise secrets daemon for LockStock Protocol - Secure Enclave in Software
|
|
5
|
+
Author-email: D3cipher <contact@d3cipher.ai>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://d3cipher.ai
|
|
8
|
+
Project-URL: Documentation, https://d3cipher.ai/docs-architecture.html
|
|
9
|
+
Project-URL: Repository, https://gitlab.com/deciphergit/lockstock
|
|
10
|
+
Project-URL: Issues, https://gitlab.com/deciphergit/lockstock/-/issues
|
|
11
|
+
Keywords: secrets,security,daemon,enterprise,lockstock,ai-agents
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Security
|
|
22
|
+
Classifier: Topic :: System :: Systems Administration
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
Requires-Dist: cryptography>=41.0.0
|
|
26
|
+
Requires-Dist: liberty-secrets>=1.0.0
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
30
|
+
|
|
31
|
+
# LockStock Guard
|
|
32
|
+
|
|
33
|
+
Enterprise secrets daemon for LockStock Protocol - "Secure Enclave in Software"
|
|
34
|
+
|
|
35
|
+
## Overview
|
|
36
|
+
|
|
37
|
+
LockStock Guard provides enterprise-grade secrets management for AI agent deployments. Unlike personal secrets managers, Guard implements user separation where the daemon holds secrets and agent applications request them via IPC.
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
AGENT SERVER
|
|
41
|
+
|
|
42
|
+
+------------------+ +---------------------------+
|
|
43
|
+
| lockstock-guard | | agent application |
|
|
44
|
+
| (liberty-user) | | (app-user) |
|
|
45
|
+
| | Unix | |
|
|
46
|
+
| - Owns vault | Socket | - Cannot read vault |
|
|
47
|
+
| - Decrypts |<------->| - Requests via IPC |
|
|
48
|
+
| - Serves | | - Gets only what needed |
|
|
49
|
+
+------------------+ +---------------------------+
|
|
50
|
+
|
|
51
|
+
/var/lib/liberty/secrets.enc (liberty:liberty, mode 600)
|
|
52
|
+
/var/run/liberty/liberty.sock (liberty:agents, mode 660)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Installation
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install lockstock-guard
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Requires:** [liberty-secrets](https://pypi.org/project/liberty-secrets/) (installed automatically)
|
|
62
|
+
|
|
63
|
+
## Quick Start
|
|
64
|
+
|
|
65
|
+
### 1. Initialize the Enterprise Vault
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
# Create enterprise vault directory (as root or liberty-user)
|
|
69
|
+
sudo mkdir -p /var/lib/liberty /var/run/liberty
|
|
70
|
+
sudo chown liberty:liberty /var/lib/liberty
|
|
71
|
+
sudo chown liberty:agents /var/run/liberty
|
|
72
|
+
sudo chmod 750 /var/lib/liberty /var/run/liberty
|
|
73
|
+
|
|
74
|
+
# Initialize vault (as liberty-user)
|
|
75
|
+
sudo -u liberty liberty --vault /var/lib/liberty init
|
|
76
|
+
|
|
77
|
+
# Add agent secrets
|
|
78
|
+
sudo -u liberty liberty --vault /var/lib/liberty add AGENT_XYZ_SECRET
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### 2. Start the Daemon
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
# Start daemon (as liberty-user or via systemd)
|
|
85
|
+
lockstock-guard start
|
|
86
|
+
|
|
87
|
+
# Or with custom paths
|
|
88
|
+
lockstock-guard start --vault /var/lib/liberty --socket /var/run/liberty/liberty.sock
|
|
89
|
+
|
|
90
|
+
# Check status
|
|
91
|
+
lockstock-guard status
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### 3. Use from Agent Application
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from lockstock_guard import client
|
|
98
|
+
|
|
99
|
+
# Get a secret (connects to daemon via socket)
|
|
100
|
+
secret = client.get("AGENT_XYZ_SECRET")
|
|
101
|
+
|
|
102
|
+
# List available keys
|
|
103
|
+
keys = client.list_keys()
|
|
104
|
+
|
|
105
|
+
# Connection pooling for multiple requests
|
|
106
|
+
with client.connect() as conn:
|
|
107
|
+
key1 = conn.get("API_KEY")
|
|
108
|
+
key2 = conn.get("DATABASE_URL")
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Systemd Service
|
|
112
|
+
|
|
113
|
+
Install the systemd service for production:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
sudo cp lockstock-guard.service /etc/systemd/system/
|
|
117
|
+
sudo systemctl daemon-reload
|
|
118
|
+
sudo systemctl enable lockstock-guard
|
|
119
|
+
sudo systemctl start lockstock-guard
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Security Model
|
|
123
|
+
|
|
124
|
+
| Component | User | Access |
|
|
125
|
+
|-----------|------|--------|
|
|
126
|
+
| Vault file | liberty-user | Read/write (mode 600) |
|
|
127
|
+
| Socket | liberty:agents | Group access (mode 660) |
|
|
128
|
+
| Daemon | liberty-user | Decrypts, serves secrets |
|
|
129
|
+
| Agent app | app-user (in agents group) | Socket only, no vault access |
|
|
130
|
+
|
|
131
|
+
**Key security properties:**
|
|
132
|
+
- Secrets never in environment variables
|
|
133
|
+
- Vault file inaccessible to agent processes
|
|
134
|
+
- Compromised agent cannot dump vault
|
|
135
|
+
- Least privilege - agent gets only requested secrets
|
|
136
|
+
|
|
137
|
+
## Integration with LockStock MCP
|
|
138
|
+
|
|
139
|
+
The Guard integrates with the LockStock MCP Wallet server:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
# MCP server configuration
|
|
143
|
+
export LIBERTY_SOCKET=/var/run/liberty/liberty.sock
|
|
144
|
+
export LOCKSTOCK_AGENT_ID=agent_xyz
|
|
145
|
+
|
|
146
|
+
# MCP Wallet retrieves secret from Guard daemon
|
|
147
|
+
# Agent never sees the secret directly
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## CLI Reference
|
|
151
|
+
|
|
152
|
+
```
|
|
153
|
+
lockstock-guard start [OPTIONS]
|
|
154
|
+
--vault PATH Vault directory (default: /var/lib/liberty)
|
|
155
|
+
--socket PATH Socket path (default: /var/run/liberty/liberty.sock)
|
|
156
|
+
--group NAME Socket group (default: agents)
|
|
157
|
+
--foreground Run in foreground (don't daemonize)
|
|
158
|
+
|
|
159
|
+
lockstock-guard stop
|
|
160
|
+
Stop the running daemon
|
|
161
|
+
|
|
162
|
+
lockstock-guard status
|
|
163
|
+
Check if daemon is running
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## License
|
|
167
|
+
|
|
168
|
+
MIT License - See LICENSE file
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
lockstock_guard/__init__.py,sha256=O59Lit8SuvbmTLbDxsnLg7FjNf2hIfdWjpeE0OjDnzo,1055
|
|
2
|
+
lockstock_guard/cli.py,sha256=Oim2VUE_nsQmKv75bpbYsSp47FSZSmJ0DvelIK-XsEA,3938
|
|
3
|
+
lockstock_guard/client.py,sha256=ZSrefFpdn4e9OUwD_Meo6lhY2DVahCA9MxoJ5voeuto,11599
|
|
4
|
+
lockstock_guard/daemon.py,sha256=k4eYI1BvUP2CqlR8JonAMAYJ2v0y23tdnoCcQgZ0gh4,17267
|
|
5
|
+
lockstock_guard-1.0.0.dist-info/METADATA,sha256=ZCPtJ_uXmzugTMIj_EbYf4-GobQwQDKsA2aF0fEMQlA,5103
|
|
6
|
+
lockstock_guard-1.0.0.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
|
|
7
|
+
lockstock_guard-1.0.0.dist-info/entry_points.txt,sha256=hmhPEKUrxoaYQOi3H2h2l4y55gjWn8RobUD0gxuTyOI,61
|
|
8
|
+
lockstock_guard-1.0.0.dist-info/top_level.txt,sha256=LHLVyMiO08cTiQgfC2r5XJLTAd-TAiX_dFD9CZwceOM,16
|
|
9
|
+
lockstock_guard-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
lockstock_guard
|