ecdat 0.2.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.
- ecdat/__init__.py +10 -0
- ecdat/__main__.py +5 -0
- ecdat/cli/__init__.py +203 -0
- ecdat/cli/commands/__init__.py +0 -0
- ecdat/cli/commands/about.py +130 -0
- ecdat/cli/commands/demo.py +116 -0
- ecdat/cli/commands/doctor.py +296 -0
- ecdat/cli/commands/help_cmd.py +205 -0
- ecdat/cli/commands/scan.py +228 -0
- ecdat/cli/commands/version_cmd.py +48 -0
- ecdat/cli/parser.py +87 -0
- ecdat/demo_project/auth/login.py +75 -0
- ecdat/demo_project/certs/cert_verify.go +81 -0
- ecdat/demo_project/keyexchange/channel.go +48 -0
- ecdat/demo_project/legacy/LegacyCrypto.java +78 -0
- ecdat/demo_project/payments/payment.py +64 -0
- ecdat/demo_project/quantum/pqc_utils.py +67 -0
- ecdat/demo_project/quantum/slh_signer.py +40 -0
- ecdat/demo_project/tokens/signing.js +54 -0
- ecdat/py.typed +0 -0
- ecdat/services/__init__.py +1 -0
- ecdat/services/crashlog.py +109 -0
- ecdat/services/demo.py +85 -0
- ecdat/services/paths.py +52 -0
- ecdat/services/scanner.py +248 -0
- ecdat/services/viewmodel.py +326 -0
- ecdat/ui/__init__.py +1 -0
- ecdat/ui/art3d.py +136 -0
- ecdat/ui/art_static.py +65 -0
- ecdat/ui/art_text.py +81 -0
- ecdat/ui/banner.py +148 -0
- ecdat/ui/console.py +119 -0
- ecdat/ui/motion.py +64 -0
- ecdat/ui/render.py +486 -0
- ecdat/ui/theme.py +173 -0
- ecdat-0.2.0.dist-info/METADATA +142 -0
- ecdat-0.2.0.dist-info/RECORD +51 -0
- ecdat-0.2.0.dist-info/WHEEL +5 -0
- ecdat-0.2.0.dist-info/entry_points.txt +2 -0
- ecdat-0.2.0.dist-info/licenses/LICENSE +21 -0
- ecdat-0.2.0.dist-info/top_level.txt +2 -0
- ecdat_core/__init__.py +6 -0
- ecdat_core/cbom_export.py +287 -0
- ecdat_core/cli.py +202 -0
- ecdat_core/detector.py +273 -0
- ecdat_core/ingestion.py +581 -0
- ecdat_core/models.py +145 -0
- ecdat_core/recommender.py +74 -0
- ecdat_core/risk_engine.py +264 -0
- ecdat_core/signature_loader.py +204 -0
- ecdat_core/signatures.json +692 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""``ecdat scan`` — run a scan and render/emit the result.
|
|
2
|
+
|
|
3
|
+
The command module is deliberately thin at import time: Rich, the scanner
|
|
4
|
+
engine, and the renderers are imported inside :func:`run` so that
|
|
5
|
+
``import ecdat.cli`` stays fast and never pulls in UI frameworks.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
17
|
+
from ecdat.services.viewmodel import ScanVM
|
|
18
|
+
from ecdat_core.models import ScanResult
|
|
19
|
+
|
|
20
|
+
NAME = "scan"
|
|
21
|
+
|
|
22
|
+
_FORMATS = ("pretty", "json", "cbom", "summary")
|
|
23
|
+
|
|
24
|
+
# Most-severe first. "quantum-safe" is intentionally absent: a quantum-safe
|
|
25
|
+
# finding must never trip --fail-on.
|
|
26
|
+
_SEVERITY = ("critical", "high", "medium", "low")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def register(subparsers: "argparse._SubParsersAction") -> None:
|
|
30
|
+
"""Attach the ``scan`` subcommand to *subparsers*."""
|
|
31
|
+
parser = subparsers.add_parser(
|
|
32
|
+
"scan",
|
|
33
|
+
help="Scan a local directory or git repository",
|
|
34
|
+
description=(
|
|
35
|
+
"Scan a directory (or an https:// Git repository) for "
|
|
36
|
+
"cryptographic artefacts and report their post-quantum risk."
|
|
37
|
+
),
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"target",
|
|
41
|
+
nargs="?",
|
|
42
|
+
default=".",
|
|
43
|
+
help="Directory to scan, or a Git URL with --git-url (default: .)",
|
|
44
|
+
)
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"--git-url",
|
|
47
|
+
action="store_true",
|
|
48
|
+
help="Treat TARGET as an https:// Git repository to clone and scan",
|
|
49
|
+
)
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
"-f",
|
|
52
|
+
"--format",
|
|
53
|
+
choices=_FORMATS,
|
|
54
|
+
default="pretty",
|
|
55
|
+
help="Output format (default: pretty)",
|
|
56
|
+
)
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"-o",
|
|
59
|
+
"--output",
|
|
60
|
+
metavar="DIR",
|
|
61
|
+
default=None,
|
|
62
|
+
help="Also write cbom.json and summary.json into DIR",
|
|
63
|
+
)
|
|
64
|
+
parser.add_argument(
|
|
65
|
+
"--fail-on",
|
|
66
|
+
choices=_SEVERITY,
|
|
67
|
+
default=None,
|
|
68
|
+
metavar="LEVEL",
|
|
69
|
+
help="Exit 1 when a finding is at or above LEVEL "
|
|
70
|
+
"(critical|high|medium|low); quantum-safe findings never count",
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--no-progress",
|
|
74
|
+
action="store_true",
|
|
75
|
+
help="Never show the progress spinner",
|
|
76
|
+
)
|
|
77
|
+
parser.add_argument(
|
|
78
|
+
"-q",
|
|
79
|
+
"--quiet",
|
|
80
|
+
action="store_true",
|
|
81
|
+
help="Suppress banner, progress, and status messages",
|
|
82
|
+
)
|
|
83
|
+
parser.add_argument(
|
|
84
|
+
"--limit",
|
|
85
|
+
type=int,
|
|
86
|
+
default=15,
|
|
87
|
+
metavar="N",
|
|
88
|
+
help="Maximum findings shown in the pretty table (default: 15)",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def run(args) -> int:
|
|
93
|
+
"""Execute a scan for the parsed *args*; return the process exit code.
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
ScanError: Propagated to :func:`ecdat.cli.main`, which maps it to the
|
|
97
|
+
exit code carried on the exception. ``BrokenPipeError`` and
|
|
98
|
+
``KeyboardInterrupt`` likewise propagate to ``main``.
|
|
99
|
+
"""
|
|
100
|
+
from rich.text import Text
|
|
101
|
+
|
|
102
|
+
from ecdat.services.scanner import classify_target, perform_scan
|
|
103
|
+
from ecdat.ui.console import make_console
|
|
104
|
+
|
|
105
|
+
no_color = True if getattr(args, "no_color", False) else None
|
|
106
|
+
out_console = make_console(no_color=no_color)
|
|
107
|
+
err_console = make_console(stderr=True, no_color=no_color)
|
|
108
|
+
|
|
109
|
+
quiet = bool(getattr(args, "quiet", False))
|
|
110
|
+
target = classify_target(args.target, force_git=args.git_url)
|
|
111
|
+
|
|
112
|
+
show_progress = (
|
|
113
|
+
not quiet
|
|
114
|
+
and not getattr(args, "no_progress", False)
|
|
115
|
+
and err_console.is_terminal
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if show_progress:
|
|
119
|
+
with err_console.status("Scanning \u2026", spinner="dots"):
|
|
120
|
+
outcome = perform_scan(target, force_git=args.git_url)
|
|
121
|
+
else:
|
|
122
|
+
outcome = perform_scan(target, force_git=args.git_url)
|
|
123
|
+
|
|
124
|
+
result = outcome.result
|
|
125
|
+
vm = outcome.vm
|
|
126
|
+
|
|
127
|
+
if args.output:
|
|
128
|
+
_write_outputs(Path(args.output), result, err_console, quiet)
|
|
129
|
+
|
|
130
|
+
output_format = args.format
|
|
131
|
+
if output_format == "json":
|
|
132
|
+
_emit_json(result.model_dump(mode="json"))
|
|
133
|
+
elif output_format == "cbom":
|
|
134
|
+
from ecdat_core.cbom_export import export_cbom
|
|
135
|
+
|
|
136
|
+
_emit_json(export_cbom(result))
|
|
137
|
+
elif output_format == "summary":
|
|
138
|
+
from ecdat_core.cbom_export import export_summary
|
|
139
|
+
|
|
140
|
+
_emit_json(export_summary(result))
|
|
141
|
+
else:
|
|
142
|
+
_render_pretty(out_console, vm, limit=args.limit, quiet=quiet)
|
|
143
|
+
|
|
144
|
+
if vm.total == 0 and not quiet:
|
|
145
|
+
err_console.print(
|
|
146
|
+
Text(
|
|
147
|
+
f"Warning: no cryptographic artefacts detected in "
|
|
148
|
+
f"{vm.files_scanned} files.",
|
|
149
|
+
style="yellow",
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
if args.fail_on is not None and _fails_at_or_above(vm, args.fail_on):
|
|
154
|
+
return 1
|
|
155
|
+
return 0
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
# Output helpers
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _emit_json(payload: dict) -> None:
|
|
164
|
+
"""Write *payload* as pretty JSON to stdout — the ONLY thing on stdout."""
|
|
165
|
+
sys.stdout.write(json.dumps(payload, indent=2) + "\n")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _write_outputs(directory: Path, result, err_console, quiet: bool) -> None:
|
|
169
|
+
"""Write ``cbom.json`` and ``summary.json`` into *directory* (UTF-8)."""
|
|
170
|
+
from ecdat.services.scanner import ScanError
|
|
171
|
+
from ecdat_core.cbom_export import export_cbom, export_summary
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
175
|
+
except OSError as exc:
|
|
176
|
+
raise ScanError(
|
|
177
|
+
f"Could not create output directory {directory}: {exc}",
|
|
178
|
+
exit_code=3,
|
|
179
|
+
) from exc
|
|
180
|
+
|
|
181
|
+
payloads = (
|
|
182
|
+
("cbom.json", export_cbom(result)),
|
|
183
|
+
("summary.json", export_summary(result)),
|
|
184
|
+
)
|
|
185
|
+
for filename, payload in payloads:
|
|
186
|
+
path = directory / filename
|
|
187
|
+
try:
|
|
188
|
+
path.write_text(
|
|
189
|
+
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
|
190
|
+
encoding="utf-8",
|
|
191
|
+
)
|
|
192
|
+
except OSError as exc:
|
|
193
|
+
raise ScanError(
|
|
194
|
+
f"Could not write {path}: {exc}",
|
|
195
|
+
exit_code=3,
|
|
196
|
+
) from exc
|
|
197
|
+
if not quiet:
|
|
198
|
+
err_console.print(f"Wrote {path}")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _render_pretty(
|
|
202
|
+
console,
|
|
203
|
+
vm: "ScanVM",
|
|
204
|
+
*,
|
|
205
|
+
limit: int,
|
|
206
|
+
quiet: bool,
|
|
207
|
+
) -> None:
|
|
208
|
+
"""Render the banner (unless quiet) and the scan report to stdout."""
|
|
209
|
+
from ecdat import __version__
|
|
210
|
+
from ecdat.ui.banner import render_banner
|
|
211
|
+
from ecdat.ui.render import scan_report
|
|
212
|
+
|
|
213
|
+
if not quiet:
|
|
214
|
+
console.print(render_banner(version=__version__))
|
|
215
|
+
console.print(scan_report(vm, limit=limit))
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _fails_at_or_above(vm: "ScanVM", level: str) -> bool:
|
|
219
|
+
"""Return ``True`` when any non-quantum-safe finding is at/above *level*."""
|
|
220
|
+
try:
|
|
221
|
+
threshold = _SEVERITY.index(level)
|
|
222
|
+
except ValueError:
|
|
223
|
+
return False
|
|
224
|
+
for finding in vm.findings:
|
|
225
|
+
rank = _SEVERITY.index(finding.risk_level) if finding.risk_level in _SEVERITY else None
|
|
226
|
+
if rank is not None and rank <= threshold:
|
|
227
|
+
return True
|
|
228
|
+
return False
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""``ecdat version`` — version, engine, signature count, Python, and OS.
|
|
2
|
+
|
|
3
|
+
Also backs the global ``--version`` flag via
|
|
4
|
+
:func:`ecdat.cli.commands.version_cmd.render_version` in
|
|
5
|
+
:mod:`ecdat.cli.parser`. Everything heavy (the signature knowledge base) is
|
|
6
|
+
imported lazily inside :func:`render_version`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
NAME = "version"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def register(subparsers: "argparse._SubParsersAction") -> None:
|
|
18
|
+
"""Attach the ``version`` subcommand to *subparsers*."""
|
|
19
|
+
subparsers.add_parser(
|
|
20
|
+
"version",
|
|
21
|
+
help="Show version, engine, signature count, Python and OS",
|
|
22
|
+
description=(
|
|
23
|
+
"Print the ECDAT version line: distribution version, engine "
|
|
24
|
+
"package, number of loaded signatures, Python version, and OS."
|
|
25
|
+
),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def render_version() -> str:
|
|
30
|
+
"""Build the single-line version string (``ecdat <ver> \u00b7 ...``)."""
|
|
31
|
+
import platform
|
|
32
|
+
|
|
33
|
+
from ecdat import __version__
|
|
34
|
+
from ecdat_core.signature_loader import get_all_signatures
|
|
35
|
+
|
|
36
|
+
signature_count = len(get_all_signatures())
|
|
37
|
+
os_name = platform.system() or "unknown"
|
|
38
|
+
return (
|
|
39
|
+
f"ecdat {__version__} \u00b7 engine ecdat_core \u00b7 "
|
|
40
|
+
f"{signature_count} signatures \u00b7 Python {platform.python_version()} "
|
|
41
|
+
f"\u00b7 {os_name}"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def run(args) -> int:
|
|
46
|
+
"""Write the version line to stdout; return exit code 0."""
|
|
47
|
+
sys.stdout.write(render_version() + "\n")
|
|
48
|
+
return 0
|
ecdat/cli/parser.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Argument parser construction for the ``ecdat`` CLI.
|
|
2
|
+
|
|
3
|
+
:func:`build_parser` assembles the global options and delegates subcommand
|
|
4
|
+
registration to each module in :mod:`ecdat.cli.commands`. Importing this
|
|
5
|
+
module must stay cheap: command modules defer heavy imports (Rich, the
|
|
6
|
+
scanner engine) to their ``run()`` functions.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from ecdat.cli.commands import about as about_command
|
|
15
|
+
from ecdat.cli.commands import demo as demo_command
|
|
16
|
+
from ecdat.cli.commands import doctor as doctor_command
|
|
17
|
+
from ecdat.cli.commands import help_cmd as help_command
|
|
18
|
+
from ecdat.cli.commands import scan as scan_command
|
|
19
|
+
from ecdat.cli.commands import version_cmd as version_command
|
|
20
|
+
|
|
21
|
+
# Command name -> command module. Every module exposes ``register`` and
|
|
22
|
+
# ``run(args) -> int``; ``run`` is looked up at call time so tests can patch it.
|
|
23
|
+
COMMANDS: dict[str, object] = {
|
|
24
|
+
scan_command.NAME: scan_command,
|
|
25
|
+
demo_command.NAME: demo_command,
|
|
26
|
+
help_command.NAME: help_command,
|
|
27
|
+
doctor_command.NAME: doctor_command,
|
|
28
|
+
about_command.NAME: about_command,
|
|
29
|
+
version_command.NAME: version_command,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _VersionAction(argparse.Action):
|
|
34
|
+
"""Print the full version line to stdout and exit ``0``.
|
|
35
|
+
|
|
36
|
+
Deliberately writes to stdout (argparse's built-in version action uses
|
|
37
|
+
stderr) so ``ecdat --version`` and ``ecdat version`` emit identical,
|
|
38
|
+
pipe-friendly payloads. The version renderer is imported lazily so
|
|
39
|
+
importing this module never pulls in the signature knowledge base.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, option_strings, dest, **kwargs):
|
|
43
|
+
super().__init__(option_strings, dest, nargs=0, **kwargs)
|
|
44
|
+
|
|
45
|
+
def __call__(self, parser, namespace, values, option_string=None):
|
|
46
|
+
from ecdat.cli.commands.version_cmd import render_version
|
|
47
|
+
|
|
48
|
+
sys.stdout.write(render_version() + "\n")
|
|
49
|
+
parser.exit(0)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
53
|
+
"""Build the top-level ``ecdat`` argument parser.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
An :class:`argparse.ArgumentParser` with the global flags
|
|
57
|
+
(``--version``, ``--no-color``, ``--debug``) and every registered
|
|
58
|
+
subcommand attached. The subcommand is optional — a bare ``ecdat``
|
|
59
|
+
invocation prints the banner and example commands.
|
|
60
|
+
"""
|
|
61
|
+
parser = argparse.ArgumentParser(
|
|
62
|
+
prog="ecdat",
|
|
63
|
+
description="Cryptographic Discovery & Analysis Tool",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
parser.add_argument(
|
|
67
|
+
"--version",
|
|
68
|
+
action=_VersionAction,
|
|
69
|
+
help="Show version info (engine, signatures, Python, OS) and exit",
|
|
70
|
+
)
|
|
71
|
+
parser.add_argument(
|
|
72
|
+
"--no-color",
|
|
73
|
+
action="store_true",
|
|
74
|
+
help="Disable coloured output",
|
|
75
|
+
)
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
"--debug",
|
|
78
|
+
action="store_true",
|
|
79
|
+
help="Print tracebacks for unexpected errors",
|
|
80
|
+
)
|
|
81
|
+
parser.set_defaults(version=False, no_color=False, debug=False)
|
|
82
|
+
|
|
83
|
+
subparsers = parser.add_subparsers(dest="command", metavar="COMMAND")
|
|
84
|
+
for module in COMMANDS.values():
|
|
85
|
+
module.register(subparsers)
|
|
86
|
+
|
|
87
|
+
return parser
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Authentication service for the enterprise SSO gateway.
|
|
2
|
+
|
|
3
|
+
Handles user login, session token generation, and RSA-based request
|
|
4
|
+
signing for the internal API mesh. This module sits on the critical path
|
|
5
|
+
for every authenticated request — a compromise here cascades to the entire
|
|
6
|
+
platform.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import time
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from Crypto.PublicKey import RSA
|
|
16
|
+
from Crypto.Signature import pkcs1_15
|
|
17
|
+
from Crypto.Hash import SHA256
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ── RSA key management ──────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
# Legacy deployment: 1024-bit RSA key for internal signing.
|
|
23
|
+
# TODO: migrate to ML-DSA before Q4 security audit.
|
|
24
|
+
_SIGNING_KEY = RSA.generate(1024, b"legacy-seed-do-not-use-in-prod")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_signing_key() -> object:
|
|
28
|
+
"""Load the RSA private key used for session token signing."""
|
|
29
|
+
return _SIGNING_KEY
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def sign_request(payload: bytes) -> bytes:
|
|
33
|
+
"""Sign an API request payload using RSA-PKCS#1 v1.5."""
|
|
34
|
+
key = load_signing_key()
|
|
35
|
+
h = SHA256.new(payload)
|
|
36
|
+
return pkcs1_15.new(key).sign(h)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def verify_request(payload: bytes, signature: bytes, pub_key: object) -> bool:
|
|
40
|
+
"""Verify an API request signature against the sender's RSA public key."""
|
|
41
|
+
h = SHA256.new(payload)
|
|
42
|
+
try:
|
|
43
|
+
pkcs1_15.new(pub_key).verify(h, signature)
|
|
44
|
+
return True
|
|
45
|
+
except (ValueError, TypeError):
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ── Session tokens ──────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
_TOKEN_EXPIRY_SECONDS = 3600
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def create_session_token(user_id: str, roles: list[str]) -> dict:
|
|
55
|
+
"""Create a signed session token with the given user context."""
|
|
56
|
+
now = int(time.time())
|
|
57
|
+
payload = {
|
|
58
|
+
"sub": user_id,
|
|
59
|
+
"roles": roles,
|
|
60
|
+
"iat": now,
|
|
61
|
+
"exp": now + _TOKEN_EXPIRY_SECONDS,
|
|
62
|
+
}
|
|
63
|
+
# Encode and sign
|
|
64
|
+
payload_bytes = str(payload).encode("utf-8")
|
|
65
|
+
signature = sign_request(payload_bytes)
|
|
66
|
+
return {"payload": payload_bytes.hex(), "sig": signature.hex()}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _legacy_hash_password(password: str) -> str:
|
|
70
|
+
"""MD5-based password hashing — kept for legacy credential migration.
|
|
71
|
+
|
|
72
|
+
DEPRECATED: This exists only so we can migrate old MD5-stored
|
|
73
|
+
credentials to bcrypt. Do NOT use for new accounts.
|
|
74
|
+
"""
|
|
75
|
+
return hashlib.md5(password.encode("utf-8")).hexdigest()
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Package certs implements X.509 certificate chain verification and
|
|
2
|
+
// signing for the internal certificate authority.
|
|
3
|
+
//
|
|
4
|
+
// WARNING: This module uses SHA-1 for certificate fingerprinting, which
|
|
5
|
+
// is deprecated due to demonstrated collision attacks. Migrate to SHA-256.
|
|
6
|
+
package certs
|
|
7
|
+
|
|
8
|
+
import (
|
|
9
|
+
"crypto/sha1"
|
|
10
|
+
"crypto/sha256"
|
|
11
|
+
"crypto/x509"
|
|
12
|
+
"encoding/hex"
|
|
13
|
+
"fmt"
|
|
14
|
+
"io"
|
|
15
|
+
"os"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
// FingerprintSHA1 returns the SHA-1 fingerprint of a DER-encoded
|
|
19
|
+
// certificate. Used by legacy monitoring dashboards.
|
|
20
|
+
//
|
|
21
|
+
// Deprecated: Use FingerprintSHA256 for all new fingerprinting.
|
|
22
|
+
func FingerprintSHA1(derBytes []byte) string {
|
|
23
|
+
h := sha1.Sum(derBytes)
|
|
24
|
+
return hex.EncodeToString(h[:])
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// FingerprintSHA256 returns the SHA-256 fingerprint of a DER-encoded
|
|
28
|
+
// certificate. This is the recommended replacement for FingerprintSHA1.
|
|
29
|
+
func FingerprintSHA256(derBytes []byte) string {
|
|
30
|
+
h := sha256.Sum256(derBytes)
|
|
31
|
+
return hex.EncodeToString(h[:])
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// VerifyCertChain verifies that the leaf certificate is signed by one
|
|
35
|
+
// of the trusted roots in the system trust store.
|
|
36
|
+
func VerifyCertChain(leaf *x509.Certificate) error {
|
|
37
|
+
pool, err := x509.SystemCertPool()
|
|
38
|
+
if err != nil {
|
|
39
|
+
return fmt.Errorf("failed to load system cert pool: %w", err)
|
|
40
|
+
}
|
|
41
|
+
opts := x509.VerifyOptions{
|
|
42
|
+
Roots: pool,
|
|
43
|
+
}
|
|
44
|
+
_, err = leaf.Verify(opts)
|
|
45
|
+
return err
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// CertFileSHA1Fingerprint reads a PEM/DER certificate file and returns
|
|
49
|
+
// its SHA-1 fingerprint. Kept for backward compat with legacy dashboards.
|
|
50
|
+
//
|
|
51
|
+
// Deprecated: Use CertFileSHA256Fingerprint instead.
|
|
52
|
+
func CertFileSHA1Fingerprint(path string) (string, error) {
|
|
53
|
+
data, err := os.ReadFile(path)
|
|
54
|
+
if err != nil {
|
|
55
|
+
return "", fmt.Errorf("read cert file: %w", err)
|
|
56
|
+
}
|
|
57
|
+
h := sha1.Sum(data)
|
|
58
|
+
return hex.EncodeToString(h[:]), nil
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// CertFileSHA256Fingerprint reads a certificate file and returns
|
|
62
|
+
// its SHA-256 fingerprint.
|
|
63
|
+
func CertFileSHA256Fingerprint(path string) (string, error) {
|
|
64
|
+
data, err := os.ReadFile(path)
|
|
65
|
+
if err != nil {
|
|
66
|
+
return "", fmt.Errorf("read cert file: %w", err)
|
|
67
|
+
}
|
|
68
|
+
h := sha256.Sum256(data)
|
|
69
|
+
return hex.EncodeToString(h[:]), nil
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// LegacyCAFingerprint returns the SHA-1 fingerprint of a certificate
|
|
73
|
+
// read from a reader. Used by the CA migration tool.
|
|
74
|
+
func LegacyCAFingerprint(r io.Reader) (string, error) {
|
|
75
|
+
data, err := io.ReadAll(r)
|
|
76
|
+
if err != nil {
|
|
77
|
+
return "", err
|
|
78
|
+
}
|
|
79
|
+
h := sha1.Sum(data)
|
|
80
|
+
return hex.EncodeToString(h[:]), nil
|
|
81
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Package keyexchange implements Diffie-Hellman key agreement for the
|
|
2
|
+
// secure inter-service communication channel.
|
|
3
|
+
//
|
|
4
|
+
// WARNING: Classic DH with 2048-bit prime is quantum-vulnerable.
|
|
5
|
+
// This module is scheduled for ML-KEM migration before production launch.
|
|
6
|
+
package keyexchange
|
|
7
|
+
|
|
8
|
+
import (
|
|
9
|
+
"crypto/dh"
|
|
10
|
+
"fmt"
|
|
11
|
+
"math/big"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
// Group14 is the 2048-bit MODP group from RFC 3526 (Group 14).
|
|
15
|
+
var Group14 *dh.Group
|
|
16
|
+
|
|
17
|
+
// DHParams holds the parameters for a Diffie-Hellman key exchange.
|
|
18
|
+
type DHParams struct {
|
|
19
|
+
Prime *big.Int
|
|
20
|
+
Generator int64
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// GenerateKeyPair creates a new DH key pair using Group 14.
|
|
24
|
+
// Returns (privateKey, publicKey) as byte slices.
|
|
25
|
+
func GenerateKeyPair() ([]byte, []byte, error) {
|
|
26
|
+
privKey, err := dh.GenerateKey(Group14)
|
|
27
|
+
if err != nil {
|
|
28
|
+
return nil, nil, fmt.Errorf("DH key generation failed: %w", err)
|
|
29
|
+
}
|
|
30
|
+
pubKey := Group14.PublicKey(privKey)
|
|
31
|
+
return privKey, pubKey.Bytes(), nil
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ComputeSharedSecret derives the shared secret from a private key
|
|
35
|
+
// and the peer's public key.
|
|
36
|
+
func ComputeSharedSecret(privKey *dh.PrivateKey, peerPubKey *dh.PublicKey) []byte {
|
|
37
|
+
return privKey.PublicKey().SharedKey(peerPubKey)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// GenerateDHKey is a convenience wrapper that generates and returns
|
|
41
|
+
// just the private key bytes (useful for single-key contexts).
|
|
42
|
+
func GenerateDHKey() ([]byte, error) {
|
|
43
|
+
privKey, err := dh.GenerateKey(Group14)
|
|
44
|
+
if err != nil {
|
|
45
|
+
return nil, err
|
|
46
|
+
}
|
|
47
|
+
return privKey.PublicKey().Bytes(), nil
|
|
48
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
package com.showcase.legacy;
|
|
2
|
+
|
|
3
|
+
import javax.crypto.Cipher;
|
|
4
|
+
import javax.crypto.KeyGenerator;
|
|
5
|
+
import javax.crypto.SecretKey;
|
|
6
|
+
import javax.crypto.spec.DESedeKeySpec;
|
|
7
|
+
import java.security.Key;
|
|
8
|
+
import java.security.MessageDigest;
|
|
9
|
+
import java.util.Base64;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Legacy cryptographic utilities — retained for backward compatibility
|
|
13
|
+
* with old client SDKs that still use 3DES and MD5-based checksums.
|
|
14
|
+
*
|
|
15
|
+
* Both algorithms are broken or deprecated and must be replaced:
|
|
16
|
+
* - 3DES: 112-bit effective key, slow, NIST retired in 2023.
|
|
17
|
+
* - MD5: collision attacks demonstrated, unsuitable for security use.
|
|
18
|
+
*/
|
|
19
|
+
public final class LegacyCrypto {
|
|
20
|
+
|
|
21
|
+
private static final String DES_EDE_CIPHER = "DESede/ECB/PKCS5Padding";
|
|
22
|
+
|
|
23
|
+
private LegacyCrypto() {}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Encrypt data with Triple DES (3DES).
|
|
27
|
+
*
|
|
28
|
+
* @param plaintext data to encrypt
|
|
29
|
+
* @param keyBytes 24-byte (192-bit) 3DES key
|
|
30
|
+
* @return Base64-encoded ciphertext
|
|
31
|
+
*/
|
|
32
|
+
public static String encrypt3DES(String plaintext, byte[] keyBytes)
|
|
33
|
+
throws Exception {
|
|
34
|
+
DESedeKeySpec spec = new DESedeKeySpec(keyBytes);
|
|
35
|
+
Key key = javax.crypto.SecretKeyFactory.getInstance("DESede")
|
|
36
|
+
.generateSecret(spec);
|
|
37
|
+
|
|
38
|
+
Cipher cipher = Cipher.getInstance(DES_EDE_CIPHER);
|
|
39
|
+
cipher.init(Cipher.ENCRYPT_MODE, key);
|
|
40
|
+
byte[] ct = cipher.doFinal(plaintext.getBytes("UTF-8"));
|
|
41
|
+
return Base64.getEncoder().encodeToString(ct);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Generate a random 3DES key.
|
|
46
|
+
*/
|
|
47
|
+
public static SecretKey generate3DESKey() throws Exception {
|
|
48
|
+
KeyGenerator kg = KeyGenerator.getInstance("DESede");
|
|
49
|
+
kg.init(168); // 168-bit key, 112-bit effective security
|
|
50
|
+
return kg.generateKey();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Compute an MD5 digest of the input bytes.
|
|
55
|
+
*
|
|
56
|
+
* @deprecated Use SHA-256 instead. MD5 has broken collision resistance.
|
|
57
|
+
*/
|
|
58
|
+
@Deprecated
|
|
59
|
+
public static String md5Checksum(byte[] data) throws Exception {
|
|
60
|
+
MessageDigest md = MessageDigest.getInstance("MD5");
|
|
61
|
+
byte[] digest = md.digest(data);
|
|
62
|
+
StringBuilder sb = new StringBuilder();
|
|
63
|
+
for (byte b : digest) {
|
|
64
|
+
sb.append(String.format("%02x", b));
|
|
65
|
+
}
|
|
66
|
+
return sb.toString();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Compute an MD5 hash of a string (used by legacy credential store).
|
|
71
|
+
*/
|
|
72
|
+
@Deprecated
|
|
73
|
+
public static String legacyCredentialHash(String credential) throws Exception {
|
|
74
|
+
MessageDigest md = MessageDigest.getInstance("MD5");
|
|
75
|
+
byte[] raw = md.digest(credential.getBytes("UTF-8"));
|
|
76
|
+
return Base64.getEncoder().encodeToString(raw);
|
|
77
|
+
}
|
|
78
|
+
}
|