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.
Files changed (51) hide show
  1. ecdat/__init__.py +10 -0
  2. ecdat/__main__.py +5 -0
  3. ecdat/cli/__init__.py +203 -0
  4. ecdat/cli/commands/__init__.py +0 -0
  5. ecdat/cli/commands/about.py +130 -0
  6. ecdat/cli/commands/demo.py +116 -0
  7. ecdat/cli/commands/doctor.py +296 -0
  8. ecdat/cli/commands/help_cmd.py +205 -0
  9. ecdat/cli/commands/scan.py +228 -0
  10. ecdat/cli/commands/version_cmd.py +48 -0
  11. ecdat/cli/parser.py +87 -0
  12. ecdat/demo_project/auth/login.py +75 -0
  13. ecdat/demo_project/certs/cert_verify.go +81 -0
  14. ecdat/demo_project/keyexchange/channel.go +48 -0
  15. ecdat/demo_project/legacy/LegacyCrypto.java +78 -0
  16. ecdat/demo_project/payments/payment.py +64 -0
  17. ecdat/demo_project/quantum/pqc_utils.py +67 -0
  18. ecdat/demo_project/quantum/slh_signer.py +40 -0
  19. ecdat/demo_project/tokens/signing.js +54 -0
  20. ecdat/py.typed +0 -0
  21. ecdat/services/__init__.py +1 -0
  22. ecdat/services/crashlog.py +109 -0
  23. ecdat/services/demo.py +85 -0
  24. ecdat/services/paths.py +52 -0
  25. ecdat/services/scanner.py +248 -0
  26. ecdat/services/viewmodel.py +326 -0
  27. ecdat/ui/__init__.py +1 -0
  28. ecdat/ui/art3d.py +136 -0
  29. ecdat/ui/art_static.py +65 -0
  30. ecdat/ui/art_text.py +81 -0
  31. ecdat/ui/banner.py +148 -0
  32. ecdat/ui/console.py +119 -0
  33. ecdat/ui/motion.py +64 -0
  34. ecdat/ui/render.py +486 -0
  35. ecdat/ui/theme.py +173 -0
  36. ecdat-0.2.0.dist-info/METADATA +142 -0
  37. ecdat-0.2.0.dist-info/RECORD +51 -0
  38. ecdat-0.2.0.dist-info/WHEEL +5 -0
  39. ecdat-0.2.0.dist-info/entry_points.txt +2 -0
  40. ecdat-0.2.0.dist-info/licenses/LICENSE +21 -0
  41. ecdat-0.2.0.dist-info/top_level.txt +2 -0
  42. ecdat_core/__init__.py +6 -0
  43. ecdat_core/cbom_export.py +287 -0
  44. ecdat_core/cli.py +202 -0
  45. ecdat_core/detector.py +273 -0
  46. ecdat_core/ingestion.py +581 -0
  47. ecdat_core/models.py +145 -0
  48. ecdat_core/recommender.py +74 -0
  49. ecdat_core/risk_engine.py +264 -0
  50. ecdat_core/signature_loader.py +204 -0
  51. ecdat_core/signatures.json +692 -0
@@ -0,0 +1,64 @@
1
+ """Payment processing module — handles card-on-file encryption and
2
+ tokenization for the PCI-DSS-scoped payment pipeline.
3
+
4
+ All card data is encrypted at rest with AES-256-GCM (hardware-accelerated,
5
+ quantum-safe at 256-bit key size) and tokenized via a format-preserving
6
+ encryption scheme before storage.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from typing import Optional
13
+
14
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
15
+
16
+
17
+ # AES-256 key for card data encryption (32 bytes = 256 bits).
18
+ _CARD_ENCRYPTION_KEY = os.urandom(32)
19
+
20
+ # Nonce size recommended for AES-GCM per NIST SP 800-38D.
21
+ _NONCE_SIZE = 12
22
+
23
+
24
+ def encrypt_card_number(card_number: str) -> dict:
25
+ """Encrypt a card number with AES-256-GCM and return ciphertext + nonce.
26
+
27
+ Args:
28
+ card_number: The plaintext PAN (primary account number).
29
+
30
+ Returns:
31
+ A dict with ``nonce`` (hex) and ``ciphertext`` (hex) fields.
32
+ """
33
+ nonce = os.urandom(_NONCE_SIZE)
34
+ aesgcm = AESGCM(_CARD_ENCRYPTION_KEY)
35
+ ct = aesgcm.encrypt(nonce, card_number.encode("utf-8"), None)
36
+ return {"nonce": nonce.hex(), "ciphertext": ct.hex()}
37
+
38
+
39
+ def decrypt_card_number(nonce_hex: str, ciphertext_hex: str) -> str:
40
+ """Decrypt a card number previously encrypted with ``encrypt_card_number``."""
41
+ nonce = bytes.fromhex(nonce_hex)
42
+ ct = bytes.fromhex(ciphertext_hex)
43
+ aesgcm = AESGCM(_CARD_ENCRYPTION_KEY)
44
+ return aesgcm.decrypt(nonce, ct, None).decode("utf-8")
45
+
46
+
47
+ def generate_encryption_key() -> bytes:
48
+ """Generate a new AES-256 encryption key.
49
+
50
+ This is used during key rotation — a new 256-bit key is generated,
51
+ old data is re-encrypted, and the old key is retired.
52
+ """
53
+ return os.urandom(32) # AES-256 (256-bit key)
54
+
55
+
56
+ def encrypt_amount(amount_cents: int, key: bytes) -> bytes:
57
+ """Encrypt a payment amount for secure transit.
58
+
59
+ Uses the caller-supplied AES key so the caller controls the
60
+ encryption lifecycle.
61
+ """
62
+ nonce = os.urandom(_NONCE_SIZE)
63
+ aesgcm = AESGCM(key)
64
+ return aesgcm.encrypt(nonce, str(amount_cents).encode(), None)
@@ -0,0 +1,67 @@
1
+ """Post-quantum cryptographic utilities — NIST-standardized algorithms.
2
+
3
+ This module wraps ML-KEM (FIPS 203) and ML-DSA (FIPS 204) for the
4
+ platform's PQC migration path. These are the recommended replacements
5
+ for RSA/ECDH/ECDSA in a post-quantum world.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from ML_KEM import MLKEM768
11
+ from ML_DSA import MLDSA65
12
+
13
+
14
+ # ── ML-KEM (Key Encapsulation Mechanism) ─────────────────────────────
15
+
16
+ def generate_kem_keypair():
17
+ """Generate an ML-KEM-768 key pair for post-quantum key exchange.
18
+
19
+ ML-KEM-768 targets NIST security level 3 (~192-bit classical security,
20
+ ~128-bit quantum security). This is the recommended parameter set for
21
+ most applications.
22
+ """
23
+ kem = MLKEM768()
24
+ enc_key, dec_key = kem.generate_key()
25
+ return enc_key, dec_key
26
+
27
+
28
+ def kem_encapsulate(enc_key: bytes) -> tuple[bytes, bytes]:
29
+ """Encapsulate a shared secret under the recipient's ML-KEM public key.
30
+
31
+ Returns:
32
+ A (ciphertext, shared_secret) tuple. The ciphertext is sent to
33
+ the recipient; the shared_secret is used for symmetric encryption.
34
+ """
35
+ kem = MLKEM768()
36
+ ct, ss = kem.encapsulate(enc_key)
37
+ return ct, ss
38
+
39
+
40
+ def kem_decapsulate(dec_key: bytes, ciphertext: bytes) -> bytes:
41
+ """Decapsulate a shared secret using the recipient's ML-KEM private key."""
42
+ kem = MLKEM768()
43
+ return kem.decapsulate(dec_key, ciphertext)
44
+
45
+
46
+ # ── ML-DSA (Digital Signature Algorithm) ─────────────────────────────
47
+
48
+ def generate_signature_keypair():
49
+ """Generate an ML-DSA-65 key pair for post-quantum signatures.
50
+
51
+ ML-DSA-65 targets NIST security level 3. Signing is fast;
52
+ verification is slower but still practical for most use cases.
53
+ """
54
+ dsa = MLDSA65()
55
+ return dsa.generate_key()
56
+
57
+
58
+ def sign(message: bytes, private_key: bytes) -> bytes:
59
+ """Sign a message with ML-DSA-65."""
60
+ dsa = MLDSA65()
61
+ return dsa.sign(private_key, message)
62
+
63
+
64
+ def verify(message: bytes, signature: bytes, public_key: bytes) -> bool:
65
+ """Verify an ML-DSA-65 signature."""
66
+ dsa = MLDSA65()
67
+ return dsa.verify(public_key, message, signature)
@@ -0,0 +1,40 @@
1
+ """SLH-DSA (SPHINCS+) signer — conservative hash-based signatures.
2
+
3
+ SLH-DSA (FIPS 205) is the NIST-standardized stateless hash-based
4
+ signature scheme. It provides a conservative alternative to ML-DSA
5
+ with well-understood security proofs based only on hash function
6
+ assumptions.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+
13
+
14
+ # SLH-DSA parameter set (SPHINCS+-SHA2-128f)
15
+ _SLHDSA_VARIANT = "SLH-DSA-SHA2-128f"
16
+
17
+
18
+ def slh_dsa_keygen():
19
+ """Generate an SLH-DSA key pair.
20
+
21
+ SLH-DSA keys are larger than ML-DSA but the security argument
22
+ is simpler: it relies only on the security of the underlying
23
+ hash function.
24
+ """
25
+ # Placeholder: real implementation would use a PQ library
26
+ private_key = hashlib.sha512(b"slh-dsa-demo-private").digest()
27
+ public_key = hashlib.sha512(b"slh-dsa-demo-public").digest()
28
+ return private_key, public_key
29
+
30
+
31
+ def slh_dsa_sign(message: bytes, private_key: bytes) -> bytes:
32
+ """Sign a message with SLH-DSA."""
33
+ h = hashlib.sha256(private_key + message).digest()
34
+ return h
35
+
36
+
37
+ def slh_dsa_verify(message: bytes, signature: bytes, public_key: bytes) -> bool:
38
+ """Verify an SLH-DSA signature."""
39
+ expected = hashlib.sha256(public_key + message).digest()
40
+ return expected == signature
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Token signing service — issues and verifies ECDSA-signed JWTs
3
+ * for the microservice authentication mesh.
4
+ *
5
+ * Uses the P-256 curve (secp256r1) which is standard for JWTs but
6
+ * quantum-vulnerable: Shor's algorithm breaks ECDSA in polynomial time.
7
+ * Migrate to ML-DSA when the ecosystem supports it.
8
+ */
9
+
10
+ const crypto = require("crypto");
11
+
12
+ const ECDSA_CURVE = "prime256v1"; // same as secp256r1 / P-256
13
+
14
+ /**
15
+ * Generate an ECDSA key pair for token signing.
16
+ * Returns { privateKey, publicKey } in PEM format.
17
+ */
18
+ function generateKeyPair() {
19
+ const { privateKey, publicKey } = crypto.generateKeyPairSync("ec", {
20
+ namedCurve: ECDSA_CURVE,
21
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
22
+ publicKeyEncoding: { type: "spki", format: "pem" },
23
+ });
24
+ return { privateKey, publicKey };
25
+ }
26
+
27
+ /**
28
+ * Sign a JSON payload with ECDSA using SHA-256.
29
+ * Returns the base64-encoded signature.
30
+ */
31
+ function signPayload(payload, privateKeyPem) {
32
+ const data = Buffer.from(JSON.stringify(payload));
33
+ const signature = crypto.sign("sha256", data, {
34
+ key: privateKeyPem,
35
+ dsaEncoding: "ieee-p1363",
36
+ });
37
+ return signature.toString("base64");
38
+ }
39
+
40
+ /**
41
+ * Verify an ECDSA signature against a JSON payload.
42
+ */
43
+ function verifyPayload(payload, signatureB64, publicKeyPem) {
44
+ const data = Buffer.from(JSON.stringify(payload));
45
+ const sig = Buffer.from(signatureB64, "base64");
46
+ return crypto.verify(
47
+ "sha256",
48
+ data,
49
+ { key: publicKeyPem, dsaEncoding: "ieee-p1363" },
50
+ sig
51
+ );
52
+ }
53
+
54
+ module.exports = { generateKeyPair, signPayload, verifyPayload };
ecdat/py.typed ADDED
File without changes
@@ -0,0 +1 @@
1
+ """ECDAT app-layer services."""
@@ -0,0 +1,109 @@
1
+ """Crash-log writer for unexpected ECDAT failures.
2
+
3
+ When :func:`ecdat.cli.main` catches an exception it cannot attribute to a
4
+ known user error, it records a crash report under
5
+ :func:`ecdat.services.paths.logs_dir` and points the user at it. Writing a
6
+ crash log must never mask the original failure, so :func:`write_crash_log`
7
+ swallows *every* error and returns ``None`` instead of raising.
8
+
9
+ Only the newest :data:`_MAX_CRASH_LOGS` reports are retained.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import platform
15
+ import sys
16
+ import traceback
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from typing import Optional, Sequence
20
+
21
+ from ecdat.services.paths import logs_dir
22
+
23
+ __all__ = ["write_crash_log"]
24
+
25
+ _MAX_CRASH_LOGS = 20
26
+
27
+
28
+ def _utc_stamp() -> str:
29
+ """Return a sortable UTC timestamp (microsecond precision)."""
30
+ return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
31
+
32
+
33
+ def _format_report(exc: BaseException, argv: Sequence[str]) -> str:
34
+ """Build the human-readable crash report body."""
35
+ from ecdat import __version__
36
+
37
+ tb = "".join(
38
+ traceback.format_exception(type(exc), exc, exc.__traceback__)
39
+ )
40
+ python = sys.version.replace("\n", " ")
41
+ return (
42
+ "ECDAT crash report\n"
43
+ "==================\n"
44
+ f"ecdat: {__version__}\n"
45
+ f"python: {python}\n"
46
+ f"platform: {platform.platform()}\n"
47
+ f"argv: {list(argv)!r}\n"
48
+ f"error: {type(exc).__name__}: {exc}\n"
49
+ "\n"
50
+ "traceback:\n"
51
+ f"{tb}"
52
+ )
53
+
54
+
55
+ def _trim(directory: Path, keep: int) -> None:
56
+ """Delete the oldest ``crash-*.log`` files, keeping the newest *keep*.
57
+
58
+ Ordering is by modification time (nanosecond resolution) with the
59
+ timestamp-prefixed name as tiebreaker. Best-effort: unlink failures are
60
+ ignored.
61
+ """
62
+
63
+ def _sort_key(path: Path):
64
+ try:
65
+ return (path.stat().st_mtime_ns, path.name)
66
+ except OSError:
67
+ return (0, path.name)
68
+
69
+ logs = sorted(directory.glob("crash-*.log"), key=_sort_key)
70
+ stale = logs[:-keep] if keep > 0 else logs
71
+ for old in stale:
72
+ try:
73
+ old.unlink()
74
+ except OSError:
75
+ pass
76
+
77
+
78
+ def write_crash_log(
79
+ exc: BaseException,
80
+ argv: Sequence[str],
81
+ ) -> Optional[Path]:
82
+ """Write a crash report for *exc* and return its path (or ``None``).
83
+
84
+ Args:
85
+ exc: The unexpected exception to record.
86
+ argv: The command-line arguments the process was launched with.
87
+
88
+ Returns:
89
+ The path of the newly written log file, or ``None`` if the log could
90
+ not be written (e.g. the data directory is not writable). This
91
+ function never raises.
92
+ """
93
+ try:
94
+ directory = logs_dir()
95
+ directory.mkdir(parents=True, exist_ok=True)
96
+
97
+ stamp = _utc_stamp()
98
+ path = directory / f"crash-{stamp}.log"
99
+ suffix = 0
100
+ while path.exists():
101
+ suffix += 1
102
+ path = directory / f"crash-{stamp}-{suffix}.log"
103
+
104
+ path.write_text(_format_report(exc, argv), encoding="utf-8")
105
+ _trim(directory, _MAX_CRASH_LOGS)
106
+ return path
107
+ except Exception:
108
+ # A crash logger that raises is worse than no crash logger.
109
+ return None
ecdat/services/demo.py ADDED
@@ -0,0 +1,85 @@
1
+ """Access to the bundled sample project shipped inside the ``ecdat`` wheel.
2
+
3
+ The demo project is a deliberately insecure copy of the engine's
4
+ ``showcase_repo`` fixture. It lives as package data, so it must be reached
5
+ through :mod:`importlib.resources` — never through ``__file__`` tricks, which
6
+ break inside zip imports and frozen distributions.
7
+
8
+ Public API:
9
+ - :func:`demo_source` — the packaged sample as a :class:`Traversable`.
10
+ - :func:`demo_project` — context manager yielding a real, writable copy of
11
+ the sample in a temporary directory.
12
+ - :func:`materialize_demo` — copy the sample to a caller-chosen directory.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import importlib.resources
18
+ import shutil
19
+ import tempfile
20
+ from contextlib import contextmanager
21
+ from pathlib import Path
22
+ from typing import TYPE_CHECKING, Iterator
23
+
24
+ if TYPE_CHECKING: # pragma: no cover - typing only
25
+ from importlib.resources.abc import Traversable
26
+
27
+ __all__ = ["demo_source", "demo_project", "materialize_demo"]
28
+
29
+
30
+ def demo_source() -> "Traversable":
31
+ """Return the bundled demo project as an importlib resource.
32
+
33
+ Returns:
34
+ A :class:`Traversable` for the ``ecdat/demo_project`` package-data
35
+ directory. It may or may not be backed by a real filesystem path.
36
+ """
37
+ return importlib.resources.files("ecdat") / "demo_project"
38
+
39
+
40
+ def _copy_tree(source: "Traversable", destination: Path) -> None:
41
+ """Recursively copy a :class:`Traversable` tree onto *destination*.
42
+
43
+ Walks the traversable directly (``iterdir``) rather than forcing it onto
44
+ disk with ``importlib.resources.as_file`` — this keeps the copy working
45
+ for non-filesystem loaders and avoids any lingering temp extraction.
46
+ """
47
+ destination.mkdir(parents=True, exist_ok=True)
48
+ for child in source.iterdir():
49
+ target = destination / child.name
50
+ if child.is_dir():
51
+ _copy_tree(child, target)
52
+ else:
53
+ target.write_bytes(child.read_bytes())
54
+
55
+
56
+ def materialize_demo(destination: Path) -> Path:
57
+ """Copy the bundled demo project into *destination* and return it.
58
+
59
+ Any pre-existing *destination* is replaced so the result is always a
60
+ clean, complete copy.
61
+
62
+ Args:
63
+ destination: Directory to (re)create and fill with the sample.
64
+
65
+ Returns:
66
+ The *destination* path, for convenient chaining.
67
+ """
68
+ if destination.exists():
69
+ shutil.rmtree(destination)
70
+ _copy_tree(demo_source(), destination)
71
+ return destination
72
+
73
+
74
+ @contextmanager
75
+ def demo_project() -> Iterator[Path]:
76
+ """Yield a writable copy of the bundled demo project.
77
+
78
+ The copy lives in a :class:`~tempfile.TemporaryDirectory` and is deleted
79
+ when the context exits — callers can mutate it freely without touching the
80
+ installed package.
81
+ """
82
+ with tempfile.TemporaryDirectory(prefix="ecdat-demo-") as tmpdir:
83
+ destination = Path(tmpdir) / "demo_project"
84
+ _copy_tree(demo_source(), destination)
85
+ yield destination
@@ -0,0 +1,52 @@
1
+ """Filesystem locations for ECDAT user data.
2
+
3
+ Pure, side-effect-free path computations — importing this module creates
4
+ nothing and touches nothing. Directories are created lazily by whoever
5
+ writes (see :mod:`ecdat.services.crashlog`).
6
+
7
+ Resolution order for :func:`home_dir`:
8
+
9
+ 1. ``ECDAT_HOME`` (environment override — used by tests and advanced users).
10
+ 2. Windows: ``%LOCALAPPDATA%\\ecdat``.
11
+ 3. macOS: ``~/Library/Application Support/ecdat``.
12
+ 4. Linux/other: ``$XDG_DATA_HOME/ecdat`` or ``~/.local/share/ecdat``.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ __all__ = ["home_dir", "logs_dir"]
22
+
23
+
24
+ def home_dir() -> Path:
25
+ """Return the ECDAT user-data root directory (not created).
26
+
27
+ Returns:
28
+ A :class:`~pathlib.Path` pointing at the app's data root. The path
29
+ may not exist yet — callers are responsible for creating it.
30
+ """
31
+ override = os.environ.get("ECDAT_HOME")
32
+ if override:
33
+ return Path(override).expanduser()
34
+
35
+ if sys.platform.startswith("win"):
36
+ local_appdata = os.environ.get("LOCALAPPDATA")
37
+ if local_appdata:
38
+ return Path(local_appdata) / "ecdat"
39
+ return Path.home() / "AppData" / "Local" / "ecdat"
40
+
41
+ if sys.platform == "darwin":
42
+ return Path.home() / "Library" / "Application Support" / "ecdat"
43
+
44
+ xdg_data_home = os.environ.get("XDG_DATA_HOME")
45
+ if xdg_data_home:
46
+ return Path(xdg_data_home) / "ecdat"
47
+ return Path.home() / ".local" / "share" / "ecdat"
48
+
49
+
50
+ def logs_dir() -> Path:
51
+ """Return the ECDAT logs directory (``home_dir()/logs``; not created)."""
52
+ return home_dir() / "logs"