bizteamai-smcp 1.13.1__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.
- bizteamai_smcp-1.13.1.dist-info/METADATA +117 -0
- bizteamai_smcp-1.13.1.dist-info/RECORD +21 -0
- bizteamai_smcp-1.13.1.dist-info/WHEEL +5 -0
- bizteamai_smcp-1.13.1.dist-info/entry_points.txt +3 -0
- bizteamai_smcp-1.13.1.dist-info/top_level.txt +1 -0
- smcp/__init__.py +29 -0
- smcp/allowlist.py +169 -0
- smcp/app_wrapper.py +216 -0
- smcp/cli/__init__.py +3 -0
- smcp/cli/approve.py +261 -0
- smcp/cli/gen_key.py +73 -0
- smcp/cli/mkcert.py +327 -0
- smcp/cli/revoke.py +73 -0
- smcp/confirm.py +262 -0
- smcp/cpu.py +67 -0
- smcp/decorators.py +97 -0
- smcp/enforce.py +100 -0
- smcp/filters.py +176 -0
- smcp/license.py +113 -0
- smcp/logchain.py +270 -0
- smcp/tls.py +160 -0
smcp/cli/approve.py
ADDED
@@ -0,0 +1,261 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
Action approval tool for SMCP.
|
4
|
+
|
5
|
+
Allows administrators to approve or reject queued destructive actions.
|
6
|
+
"""
|
7
|
+
|
8
|
+
import argparse
|
9
|
+
import json
|
10
|
+
import sys
|
11
|
+
import time
|
12
|
+
from pathlib import Path
|
13
|
+
from typing import Dict, List, Optional
|
14
|
+
|
15
|
+
try:
|
16
|
+
import yaml
|
17
|
+
YAML_AVAILABLE = True
|
18
|
+
except ImportError:
|
19
|
+
YAML_AVAILABLE = False
|
20
|
+
|
21
|
+
from ..confirm import ActionQueue, format_action_summary
|
22
|
+
|
23
|
+
|
24
|
+
def load_config(config_path: Optional[str] = None) -> Dict:
|
25
|
+
"""Load configuration from file or environment."""
|
26
|
+
config = {}
|
27
|
+
|
28
|
+
# Try to load from config file
|
29
|
+
if config_path:
|
30
|
+
config_file = Path(config_path)
|
31
|
+
if config_file.exists():
|
32
|
+
if config_file.suffix.lower() in ['.yaml', '.yml'] and YAML_AVAILABLE:
|
33
|
+
with open(config_file, 'r') as f:
|
34
|
+
config = yaml.safe_load(f) or {}
|
35
|
+
elif config_file.suffix.lower() == '.json':
|
36
|
+
with open(config_file, 'r') as f:
|
37
|
+
config = json.load(f)
|
38
|
+
|
39
|
+
# Override with environment variables
|
40
|
+
import os
|
41
|
+
env_config = {
|
42
|
+
"QUEUE_FILE": os.getenv("SMCP_QUEUE_FILE"),
|
43
|
+
"LOG_PATH": os.getenv("SMCP_LOG_PATH"),
|
44
|
+
}
|
45
|
+
|
46
|
+
# Add non-None environment variables
|
47
|
+
for key, value in env_config.items():
|
48
|
+
if value is not None:
|
49
|
+
config[key] = value
|
50
|
+
|
51
|
+
return config
|
52
|
+
|
53
|
+
|
54
|
+
def list_pending_actions(queue: ActionQueue) -> List[Dict]:
|
55
|
+
"""List all pending actions."""
|
56
|
+
return queue.get_pending_actions()
|
57
|
+
|
58
|
+
|
59
|
+
def display_action_details(action: Dict) -> None:
|
60
|
+
"""Display detailed information about an action."""
|
61
|
+
print("=" * 60)
|
62
|
+
print(format_action_summary(action))
|
63
|
+
print("=" * 60)
|
64
|
+
|
65
|
+
|
66
|
+
def approve_action(queue: ActionQueue, action_id: str) -> bool:
|
67
|
+
"""Approve an action."""
|
68
|
+
action = queue.approve_action(action_id)
|
69
|
+
if action:
|
70
|
+
print(f"✓ Action {action_id} approved")
|
71
|
+
return True
|
72
|
+
else:
|
73
|
+
print(f"✗ Action {action_id} not found or already processed")
|
74
|
+
return False
|
75
|
+
|
76
|
+
|
77
|
+
def reject_action(queue: ActionQueue, action_id: str) -> bool:
|
78
|
+
"""Reject an action."""
|
79
|
+
if queue.reject_action(action_id):
|
80
|
+
print(f"✗ Action {action_id} rejected")
|
81
|
+
return True
|
82
|
+
else:
|
83
|
+
print(f"✗ Action {action_id} not found or already processed")
|
84
|
+
return False
|
85
|
+
|
86
|
+
|
87
|
+
def interactive_approval(queue: ActionQueue) -> None:
|
88
|
+
"""Interactive approval mode."""
|
89
|
+
print("Interactive approval mode. Type 'help' for commands.")
|
90
|
+
|
91
|
+
while True:
|
92
|
+
try:
|
93
|
+
command = input("\nsmcp-approve> ").strip().lower()
|
94
|
+
|
95
|
+
if command in ['quit', 'exit', 'q']:
|
96
|
+
break
|
97
|
+
elif command == 'help':
|
98
|
+
print("Commands:")
|
99
|
+
print(" list - List all pending actions")
|
100
|
+
print(" show <action-id> - Show action details")
|
101
|
+
print(" approve <action-id> - Approve an action")
|
102
|
+
print(" reject <action-id> - Reject an action")
|
103
|
+
print(" cleanup [hours] - Clean up old actions")
|
104
|
+
print(" quit/exit/q - Exit interactive mode")
|
105
|
+
elif command == 'list':
|
106
|
+
actions = list_pending_actions(queue)
|
107
|
+
if not actions:
|
108
|
+
print("No pending actions")
|
109
|
+
else:
|
110
|
+
print(f"\n{len(actions)} pending action(s):")
|
111
|
+
for action in actions:
|
112
|
+
timestamp = time.strftime("%H:%M:%S", time.localtime(action["timestamp"]))
|
113
|
+
print(f" {action['id'][:8]}... - {action['function_name']} ({timestamp})")
|
114
|
+
elif command.startswith('show '):
|
115
|
+
action_id = command[5:].strip()
|
116
|
+
actions = list_pending_actions(queue)
|
117
|
+
action = next((a for a in actions if a['id'].startswith(action_id)), None)
|
118
|
+
if action:
|
119
|
+
display_action_details(action)
|
120
|
+
else:
|
121
|
+
print(f"Action not found: {action_id}")
|
122
|
+
elif command.startswith('approve '):
|
123
|
+
action_id = command[8:].strip()
|
124
|
+
# Find full action ID from partial
|
125
|
+
actions = list_pending_actions(queue)
|
126
|
+
action = next((a for a in actions if a['id'].startswith(action_id)), None)
|
127
|
+
if action:
|
128
|
+
approve_action(queue, action['id'])
|
129
|
+
else:
|
130
|
+
print(f"Action not found: {action_id}")
|
131
|
+
elif command.startswith('reject '):
|
132
|
+
action_id = command[7:].strip()
|
133
|
+
# Find full action ID from partial
|
134
|
+
actions = list_pending_actions(queue)
|
135
|
+
action = next((a for a in actions if a['id'].startswith(action_id)), None)
|
136
|
+
if action:
|
137
|
+
reject_action(queue, action['id'])
|
138
|
+
else:
|
139
|
+
print(f"Action not found: {action_id}")
|
140
|
+
elif command.startswith('cleanup'):
|
141
|
+
parts = command.split()
|
142
|
+
hours = int(parts[1]) if len(parts) > 1 else 24
|
143
|
+
removed = queue.cleanup_old_actions(hours)
|
144
|
+
print(f"Removed {removed} old action(s)")
|
145
|
+
else:
|
146
|
+
print(f"Unknown command: {command}. Type 'help' for available commands.")
|
147
|
+
|
148
|
+
except (KeyboardInterrupt, EOFError):
|
149
|
+
print("\nExiting...")
|
150
|
+
break
|
151
|
+
except Exception as e:
|
152
|
+
print(f"Error: {e}")
|
153
|
+
|
154
|
+
|
155
|
+
def main() -> None:
|
156
|
+
"""Main entry point for the approve CLI tool."""
|
157
|
+
parser = argparse.ArgumentParser(
|
158
|
+
description="Approve or reject queued SMCP actions"
|
159
|
+
)
|
160
|
+
parser.add_argument(
|
161
|
+
"action_id",
|
162
|
+
nargs="?",
|
163
|
+
help="Action ID to approve (if not provided, enters interactive mode)"
|
164
|
+
)
|
165
|
+
parser.add_argument(
|
166
|
+
"--reject", "-r",
|
167
|
+
action="store_true",
|
168
|
+
help="Reject the action instead of approving it"
|
169
|
+
)
|
170
|
+
parser.add_argument(
|
171
|
+
"--list", "-l",
|
172
|
+
action="store_true",
|
173
|
+
help="List all pending actions and exit"
|
174
|
+
)
|
175
|
+
parser.add_argument(
|
176
|
+
"--config", "-c",
|
177
|
+
help="Path to configuration file"
|
178
|
+
)
|
179
|
+
parser.add_argument(
|
180
|
+
"--queue-file",
|
181
|
+
help="Path to the action queue file (overrides config)"
|
182
|
+
)
|
183
|
+
parser.add_argument(
|
184
|
+
"--cleanup",
|
185
|
+
type=int,
|
186
|
+
metavar="HOURS",
|
187
|
+
help="Clean up actions older than specified hours"
|
188
|
+
)
|
189
|
+
parser.add_argument(
|
190
|
+
"--interactive", "-i",
|
191
|
+
action="store_true",
|
192
|
+
help="Enter interactive approval mode"
|
193
|
+
)
|
194
|
+
|
195
|
+
args = parser.parse_args()
|
196
|
+
|
197
|
+
# Load configuration
|
198
|
+
config = load_config(args.config)
|
199
|
+
|
200
|
+
# Override queue file if specified
|
201
|
+
if args.queue_file:
|
202
|
+
config["QUEUE_FILE"] = args.queue_file
|
203
|
+
|
204
|
+
# Initialize action queue
|
205
|
+
queue_file = config.get("QUEUE_FILE", "/tmp/smcp_queue.json")
|
206
|
+
queue = ActionQueue(queue_file)
|
207
|
+
|
208
|
+
try:
|
209
|
+
# Handle cleanup
|
210
|
+
if args.cleanup:
|
211
|
+
removed = queue.cleanup_old_actions(args.cleanup)
|
212
|
+
print(f"Removed {removed} old action(s)")
|
213
|
+
return
|
214
|
+
|
215
|
+
# Handle list command
|
216
|
+
if args.list:
|
217
|
+
actions = list_pending_actions(queue)
|
218
|
+
if not actions:
|
219
|
+
print("No pending actions")
|
220
|
+
else:
|
221
|
+
print(f"{len(actions)} pending action(s):")
|
222
|
+
for action in actions:
|
223
|
+
timestamp = time.strftime("%Y-%m-%d %H:%M:%S",
|
224
|
+
time.localtime(action["timestamp"]))
|
225
|
+
print(f" {action['id']} - {action['function_name']} ({timestamp})")
|
226
|
+
return
|
227
|
+
|
228
|
+
# Handle interactive mode
|
229
|
+
if args.interactive or not args.action_id:
|
230
|
+
interactive_approval(queue)
|
231
|
+
return
|
232
|
+
|
233
|
+
# Handle specific action approval/rejection
|
234
|
+
if args.action_id:
|
235
|
+
# Find action (support partial IDs)
|
236
|
+
actions = list_pending_actions(queue)
|
237
|
+
action = next((a for a in actions if a['id'].startswith(args.action_id)), None)
|
238
|
+
|
239
|
+
if not action:
|
240
|
+
print(f"Action not found: {args.action_id}")
|
241
|
+
sys.exit(1)
|
242
|
+
|
243
|
+
action_id = action['id']
|
244
|
+
|
245
|
+
# Show action details
|
246
|
+
display_action_details(action)
|
247
|
+
|
248
|
+
if args.reject:
|
249
|
+
success = reject_action(queue, action_id)
|
250
|
+
else:
|
251
|
+
success = approve_action(queue, action_id)
|
252
|
+
|
253
|
+
sys.exit(0 if success else 1)
|
254
|
+
|
255
|
+
except Exception as e:
|
256
|
+
print(f"Error: {e}")
|
257
|
+
sys.exit(1)
|
258
|
+
|
259
|
+
|
260
|
+
if __name__ == "__main__":
|
261
|
+
main()
|
smcp/cli/gen_key.py
ADDED
@@ -0,0 +1,73 @@
|
|
1
|
+
!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
License key generation utility for SMCP Business Edition.
|
4
|
+
"""
|
5
|
+
import sys
|
6
|
+
import hmac
|
7
|
+
import hashlib
|
8
|
+
import secrets
|
9
|
+
import argparse
|
10
|
+
from datetime import datetime, timedelta, timezone
|
11
|
+
|
12
|
+
def generate_license_key(customer_id: str, cores: int, days: int, secret: str) -> str:
|
13
|
+
"""Generate a new license key."""
|
14
|
+
# Generate expiry date
|
15
|
+
expiry_date = datetime.now(timezone.utc) + timedelta(days=days)
|
16
|
+
expiry_str = expiry_date.strftime('%Y%m%d')
|
17
|
+
|
18
|
+
# Generate random nonce
|
19
|
+
nonce = secrets.token_hex(8)
|
20
|
+
|
21
|
+
# Create payload for signing
|
22
|
+
payload = f"BZT.{customer_id}.{cores}.{expiry_str}.{nonce}"
|
23
|
+
|
24
|
+
# Generate HMAC signature
|
25
|
+
signature = hmac.new(
|
26
|
+
secret.encode('utf-8'),
|
27
|
+
payload.encode('utf-8'),
|
28
|
+
hashlib.sha256
|
29
|
+
).hexdigest()
|
30
|
+
|
31
|
+
return f"{payload}.{signature}"
|
32
|
+
|
33
|
+
def main():
|
34
|
+
parser = argparse.ArgumentParser(description='Generate SMCP Business Edition license keys')
|
35
|
+
parser.add_argument('--customer', '-c', required=True, help='Customer ID')
|
36
|
+
parser.add_argument('--cores', '-n', type=int, required=True, help='Number of cores')
|
37
|
+
parser.add_argument('--days', '-d', type=int, required=True, help='Validity period in days')
|
38
|
+
parser.add_argument('--secret', '-s', help='Server secret (default: use BIZTEAM_SERVER_SECRET env var)')
|
39
|
+
parser.add_argument('--output', '-o', help='Output file (default: stdout)')
|
40
|
+
|
41
|
+
args = parser.parse_args()
|
42
|
+
|
43
|
+
# Get server secret
|
44
|
+
secret = args.secret
|
45
|
+
if not secret:
|
46
|
+
import os
|
47
|
+
secret = os.getenv('BIZTEAM_SERVER_SECRET')
|
48
|
+
if not secret:
|
49
|
+
print("Error: Server secret required. Use --secret or set BIZTEAM_SERVER_SECRET", file=sys.stderr)
|
50
|
+
sys.exit(1)
|
51
|
+
|
52
|
+
# Generate key
|
53
|
+
try:
|
54
|
+
license_key = generate_license_key(args.customer, args.cores, args.days, secret)
|
55
|
+
|
56
|
+
# Output
|
57
|
+
if args.output:
|
58
|
+
with open(args.output, 'w') as f:
|
59
|
+
f.write(license_key + '\n')
|
60
|
+
print(f"License key written to {args.output}")
|
61
|
+
else:
|
62
|
+
print(license_key)
|
63
|
+
|
64
|
+
# Log for audit (in production, this would go to a database)
|
65
|
+
key_hash = hashlib.sha256(license_key.encode()).hexdigest()
|
66
|
+
print(f"# Audit: Customer={args.customer}, Cores={args.cores}, Days={args.days}, Hash={key_hash}", file=sys.stderr)
|
67
|
+
|
68
|
+
except Exception as e:
|
69
|
+
print(f"Error generating license key: {e}", file=sys.stderr)
|
70
|
+
sys.exit(1)
|
71
|
+
|
72
|
+
if __name__ == '__main__':
|
73
|
+
main()
|
smcp/cli/mkcert.py
ADDED
@@ -0,0 +1,327 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
Certificate generation tool for SMCP.
|
4
|
+
|
5
|
+
Creates a private CA and generates server/client certificates for mutual TLS.
|
6
|
+
"""
|
7
|
+
|
8
|
+
import argparse
|
9
|
+
import os
|
10
|
+
import sys
|
11
|
+
from pathlib import Path
|
12
|
+
from typing import Optional
|
13
|
+
|
14
|
+
try:
|
15
|
+
from cryptography import x509
|
16
|
+
from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID
|
17
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
18
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
19
|
+
from datetime import datetime, timedelta
|
20
|
+
CRYPTOGRAPHY_AVAILABLE = True
|
21
|
+
except ImportError:
|
22
|
+
CRYPTOGRAPHY_AVAILABLE = False
|
23
|
+
|
24
|
+
|
25
|
+
def generate_private_key() -> "rsa.RSAPrivateKey":
|
26
|
+
"""Generate a new RSA private key."""
|
27
|
+
return rsa.generate_private_key(
|
28
|
+
public_exponent=65537,
|
29
|
+
key_size=2048,
|
30
|
+
)
|
31
|
+
|
32
|
+
|
33
|
+
def create_ca_certificate(
|
34
|
+
private_key: "rsa.RSAPrivateKey",
|
35
|
+
ca_name: str,
|
36
|
+
days: int = 365
|
37
|
+
) -> "x509.Certificate":
|
38
|
+
"""Create a self-signed CA certificate."""
|
39
|
+
subject = issuer = x509.Name([
|
40
|
+
x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
|
41
|
+
x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, "CA"),
|
42
|
+
x509.NameAttribute(NameOID.LOCALITY_NAME, "San Francisco"),
|
43
|
+
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "SMCP"),
|
44
|
+
x509.NameAttribute(NameOID.COMMON_NAME, ca_name),
|
45
|
+
])
|
46
|
+
|
47
|
+
cert = x509.CertificateBuilder().subject_name(
|
48
|
+
subject
|
49
|
+
).issuer_name(
|
50
|
+
issuer
|
51
|
+
).public_key(
|
52
|
+
private_key.public_key()
|
53
|
+
).serial_number(
|
54
|
+
x509.random_serial_number()
|
55
|
+
).not_valid_before(
|
56
|
+
datetime.utcnow()
|
57
|
+
).not_valid_after(
|
58
|
+
datetime.utcnow() + timedelta(days=days)
|
59
|
+
).add_extension(
|
60
|
+
x509.SubjectAlternativeName([
|
61
|
+
x509.DNSName(ca_name),
|
62
|
+
]),
|
63
|
+
critical=False,
|
64
|
+
).add_extension(
|
65
|
+
x509.BasicConstraints(ca=True, path_length=None),
|
66
|
+
critical=True,
|
67
|
+
).add_extension(
|
68
|
+
x509.KeyUsage(
|
69
|
+
digital_signature=True,
|
70
|
+
content_commitment=False,
|
71
|
+
key_encipherment=False,
|
72
|
+
data_encipherment=False,
|
73
|
+
key_agreement=False,
|
74
|
+
key_cert_sign=True,
|
75
|
+
crl_sign=True,
|
76
|
+
encipher_only=False,
|
77
|
+
decipher_only=False,
|
78
|
+
),
|
79
|
+
critical=True,
|
80
|
+
).sign(private_key, hashes.SHA256())
|
81
|
+
|
82
|
+
return cert
|
83
|
+
|
84
|
+
|
85
|
+
def create_server_certificate(
|
86
|
+
private_key: "rsa.RSAPrivateKey",
|
87
|
+
ca_cert: "x509.Certificate",
|
88
|
+
ca_private_key: "rsa.RSAPrivateKey",
|
89
|
+
server_name: str,
|
90
|
+
days: int = 365
|
91
|
+
) -> "x509.Certificate":
|
92
|
+
"""Create a server certificate signed by the CA."""
|
93
|
+
subject = x509.Name([
|
94
|
+
x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
|
95
|
+
x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, "CA"),
|
96
|
+
x509.NameAttribute(NameOID.LOCALITY_NAME, "San Francisco"),
|
97
|
+
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "SMCP"),
|
98
|
+
x509.NameAttribute(NameOID.COMMON_NAME, server_name),
|
99
|
+
])
|
100
|
+
|
101
|
+
cert = x509.CertificateBuilder().subject_name(
|
102
|
+
subject
|
103
|
+
).issuer_name(
|
104
|
+
ca_cert.subject
|
105
|
+
).public_key(
|
106
|
+
private_key.public_key()
|
107
|
+
).serial_number(
|
108
|
+
x509.random_serial_number()
|
109
|
+
).not_valid_before(
|
110
|
+
datetime.utcnow()
|
111
|
+
).not_valid_after(
|
112
|
+
datetime.utcnow() + timedelta(days=days)
|
113
|
+
).add_extension(
|
114
|
+
x509.SubjectAlternativeName([
|
115
|
+
x509.DNSName(server_name),
|
116
|
+
x509.DNSName("localhost"),
|
117
|
+
x509.IPAddress("127.0.0.1"),
|
118
|
+
]),
|
119
|
+
critical=False,
|
120
|
+
).add_extension(
|
121
|
+
x509.BasicConstraints(ca=False, path_length=None),
|
122
|
+
critical=True,
|
123
|
+
).add_extension(
|
124
|
+
x509.KeyUsage(
|
125
|
+
digital_signature=True,
|
126
|
+
content_commitment=False,
|
127
|
+
key_encipherment=True,
|
128
|
+
data_encipherment=False,
|
129
|
+
key_agreement=False,
|
130
|
+
key_cert_sign=False,
|
131
|
+
crl_sign=False,
|
132
|
+
encipher_only=False,
|
133
|
+
decipher_only=False,
|
134
|
+
),
|
135
|
+
critical=True,
|
136
|
+
).add_extension(
|
137
|
+
x509.ExtendedKeyUsage([
|
138
|
+
ExtendedKeyUsageOID.SERVER_AUTH,
|
139
|
+
]),
|
140
|
+
critical=True,
|
141
|
+
).sign(ca_private_key, hashes.SHA256())
|
142
|
+
|
143
|
+
return cert
|
144
|
+
|
145
|
+
|
146
|
+
def create_client_certificate(
|
147
|
+
private_key: "rsa.RSAPrivateKey",
|
148
|
+
ca_cert: "x509.Certificate",
|
149
|
+
ca_private_key: "rsa.RSAPrivateKey",
|
150
|
+
client_name: str,
|
151
|
+
days: int = 365
|
152
|
+
) -> "x509.Certificate":
|
153
|
+
"""Create a client certificate signed by the CA."""
|
154
|
+
subject = x509.Name([
|
155
|
+
x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
|
156
|
+
x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, "CA"),
|
157
|
+
x509.NameAttribute(NameOID.LOCALITY_NAME, "San Francisco"),
|
158
|
+
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "SMCP"),
|
159
|
+
x509.NameAttribute(NameOID.COMMON_NAME, client_name),
|
160
|
+
])
|
161
|
+
|
162
|
+
cert = x509.CertificateBuilder().subject_name(
|
163
|
+
subject
|
164
|
+
).issuer_name(
|
165
|
+
ca_cert.subject
|
166
|
+
).public_key(
|
167
|
+
private_key.public_key()
|
168
|
+
).serial_number(
|
169
|
+
x509.random_serial_number()
|
170
|
+
).not_valid_before(
|
171
|
+
datetime.utcnow()
|
172
|
+
).not_valid_after(
|
173
|
+
datetime.utcnow() + timedelta(days=days)
|
174
|
+
).add_extension(
|
175
|
+
x509.BasicConstraints(ca=False, path_length=None),
|
176
|
+
critical=True,
|
177
|
+
).add_extension(
|
178
|
+
x509.KeyUsage(
|
179
|
+
digital_signature=True,
|
180
|
+
content_commitment=False,
|
181
|
+
key_encipherment=True,
|
182
|
+
data_encipherment=False,
|
183
|
+
key_agreement=False,
|
184
|
+
key_cert_sign=False,
|
185
|
+
crl_sign=False,
|
186
|
+
encipher_only=False,
|
187
|
+
decipher_only=False,
|
188
|
+
),
|
189
|
+
critical=True,
|
190
|
+
).add_extension(
|
191
|
+
x509.ExtendedKeyUsage([
|
192
|
+
ExtendedKeyUsageOID.CLIENT_AUTH,
|
193
|
+
]),
|
194
|
+
critical=True,
|
195
|
+
).sign(ca_private_key, hashes.SHA256())
|
196
|
+
|
197
|
+
return cert
|
198
|
+
|
199
|
+
|
200
|
+
def save_private_key(private_key: "rsa.RSAPrivateKey", path: Path) -> None:
|
201
|
+
"""Save a private key to a PEM file."""
|
202
|
+
with open(path, "wb") as f:
|
203
|
+
f.write(private_key.private_bytes(
|
204
|
+
encoding=serialization.Encoding.PEM,
|
205
|
+
format=serialization.PrivateFormat.PKCS8,
|
206
|
+
encryption_algorithm=serialization.NoEncryption()
|
207
|
+
))
|
208
|
+
os.chmod(path, 0o600) # Make private key readable only by owner
|
209
|
+
|
210
|
+
|
211
|
+
def save_certificate(cert: "x509.Certificate", path: Path) -> None:
|
212
|
+
"""Save a certificate to a PEM file."""
|
213
|
+
with open(path, "wb") as f:
|
214
|
+
f.write(cert.public_bytes(serialization.Encoding.PEM))
|
215
|
+
|
216
|
+
|
217
|
+
def generate_certificates(
|
218
|
+
output_dir: Path,
|
219
|
+
ca_name: str,
|
220
|
+
server_name: str,
|
221
|
+
client_name: Optional[str] = None,
|
222
|
+
days: int = 365
|
223
|
+
) -> None:
|
224
|
+
"""Generate a complete certificate set."""
|
225
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
226
|
+
|
227
|
+
print(f"Generating certificates in {output_dir}")
|
228
|
+
|
229
|
+
# Generate CA
|
230
|
+
print("1. Generating CA private key...")
|
231
|
+
ca_private_key = generate_private_key()
|
232
|
+
save_private_key(ca_private_key, output_dir / "ca-key.pem")
|
233
|
+
|
234
|
+
print("2. Creating CA certificate...")
|
235
|
+
ca_cert = create_ca_certificate(ca_private_key, ca_name, days)
|
236
|
+
save_certificate(ca_cert, output_dir / "ca.pem")
|
237
|
+
|
238
|
+
# Generate server certificate
|
239
|
+
print("3. Generating server private key...")
|
240
|
+
server_private_key = generate_private_key()
|
241
|
+
save_private_key(server_private_key, output_dir / "server-key.pem")
|
242
|
+
|
243
|
+
print("4. Creating server certificate...")
|
244
|
+
server_cert = create_server_certificate(
|
245
|
+
server_private_key, ca_cert, ca_private_key, server_name, days
|
246
|
+
)
|
247
|
+
save_certificate(server_cert, output_dir / "server.pem")
|
248
|
+
|
249
|
+
# Generate client certificate if requested
|
250
|
+
if client_name:
|
251
|
+
print("5. Generating client private key...")
|
252
|
+
client_private_key = generate_private_key()
|
253
|
+
save_private_key(client_private_key, output_dir / "client-key.pem")
|
254
|
+
|
255
|
+
print("6. Creating client certificate...")
|
256
|
+
client_cert = create_client_certificate(
|
257
|
+
client_private_key, ca_cert, ca_private_key, client_name, days
|
258
|
+
)
|
259
|
+
save_certificate(client_cert, output_dir / "client.pem")
|
260
|
+
|
261
|
+
print("\nCertificate generation complete!")
|
262
|
+
print(f"Files created in {output_dir}:")
|
263
|
+
print(" ca.pem - CA certificate")
|
264
|
+
print(" ca-key.pem - CA private key")
|
265
|
+
print(" server.pem - Server certificate")
|
266
|
+
print(" server-key.pem - Server private key")
|
267
|
+
if client_name:
|
268
|
+
print(" client.pem - Client certificate")
|
269
|
+
print(" client-key.pem - Client private key")
|
270
|
+
|
271
|
+
print(f"\nCertificates valid for {days} days")
|
272
|
+
|
273
|
+
|
274
|
+
def main() -> None:
|
275
|
+
"""Main entry point for the mkcert CLI tool."""
|
276
|
+
if not CRYPTOGRAPHY_AVAILABLE:
|
277
|
+
print("Error: cryptography package is required for certificate generation")
|
278
|
+
print("Install it with: pip install cryptography")
|
279
|
+
sys.exit(1)
|
280
|
+
|
281
|
+
parser = argparse.ArgumentParser(
|
282
|
+
description="Generate certificates for SMCP mutual TLS"
|
283
|
+
)
|
284
|
+
parser.add_argument(
|
285
|
+
"--output-dir", "-o",
|
286
|
+
type=Path,
|
287
|
+
default=Path("./certs"),
|
288
|
+
help="Output directory for certificates (default: ./certs)"
|
289
|
+
)
|
290
|
+
parser.add_argument(
|
291
|
+
"--ca-name",
|
292
|
+
default="SMCP-CA",
|
293
|
+
help="Name for the Certificate Authority (default: SMCP-CA)"
|
294
|
+
)
|
295
|
+
parser.add_argument(
|
296
|
+
"--server-name",
|
297
|
+
default="smcp-server",
|
298
|
+
help="Server name for certificate (default: smcp-server)"
|
299
|
+
)
|
300
|
+
parser.add_argument(
|
301
|
+
"--client-name",
|
302
|
+
help="Client name for certificate (optional)"
|
303
|
+
)
|
304
|
+
parser.add_argument(
|
305
|
+
"--days",
|
306
|
+
type=int,
|
307
|
+
default=365,
|
308
|
+
help="Certificate validity period in days (default: 365)"
|
309
|
+
)
|
310
|
+
|
311
|
+
args = parser.parse_args()
|
312
|
+
|
313
|
+
try:
|
314
|
+
generate_certificates(
|
315
|
+
output_dir=args.output_dir,
|
316
|
+
ca_name=args.ca_name,
|
317
|
+
server_name=args.server_name,
|
318
|
+
client_name=args.client_name,
|
319
|
+
days=args.days
|
320
|
+
)
|
321
|
+
except Exception as e:
|
322
|
+
print(f"Error generating certificates: {e}")
|
323
|
+
sys.exit(1)
|
324
|
+
|
325
|
+
|
326
|
+
if __name__ == "__main__":
|
327
|
+
main()
|