ikiprotect 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.
Files changed (53) hide show
  1. iki_protect/__init__.py +5 -0
  2. iki_protect/__main__.py +4 -0
  3. iki_protect/cli/__init__.py +1 -0
  4. iki_protect/cli/app.py +47 -0
  5. iki_protect/cli/commands/__init__.py +3 -0
  6. iki_protect/cli/commands/decrypt.py +152 -0
  7. iki_protect/cli/commands/detect.py +123 -0
  8. iki_protect/cli/commands/encrypt.py +172 -0
  9. iki_protect/cli/commands/hash_cmd.py +337 -0
  10. iki_protect/cli/commands/jwt_cmd.py +135 -0
  11. iki_protect/cli/commands/keys.py +127 -0
  12. iki_protect/cli/commands/mask.py +54 -0
  13. iki_protect/cli/commands/sign.py +56 -0
  14. iki_protect/cli/facade.py +5 -0
  15. iki_protect/content/__init__.py +1 -0
  16. iki_protect/content/base.py +34 -0
  17. iki_protect/content/config_file.py +46 -0
  18. iki_protect/content/plain_text.py +10 -0
  19. iki_protect/core/__init__.py +11 -0
  20. iki_protect/core/config.py +62 -0
  21. iki_protect/core/detectors/__init__.py +30 -0
  22. iki_protect/core/detectors/base.py +41 -0
  23. iki_protect/core/detectors/regex_detectors.py +233 -0
  24. iki_protect/core/detectors/registry.py +108 -0
  25. iki_protect/core/errors.py +26 -0
  26. iki_protect/core/keys/__init__.py +5 -0
  27. iki_protect/core/keys/kms.py +119 -0
  28. iki_protect/core/keys/manager.py +198 -0
  29. iki_protect/core/keys/sss.py +54 -0
  30. iki_protect/core/strategies/__init__.py +1 -0
  31. iki_protect/core/strategies/encryption/__init__.py +1 -0
  32. iki_protect/core/strategies/encryption/hpke.py +102 -0
  33. iki_protect/core/strategies/encryption/hybrid_kem.py +51 -0
  34. iki_protect/core/strategies/encryption/symmetric.py +419 -0
  35. iki_protect/core/strategies/hashing/__init__.py +1 -0
  36. iki_protect/core/strategies/hashing/envelope.py +154 -0
  37. iki_protect/core/strategies/hashing/fast_hash.py +101 -0
  38. iki_protect/core/strategies/hashing/kdf.py +25 -0
  39. iki_protect/core/strategies/hashing/slow_hash.py +119 -0
  40. iki_protect/core/strategies/jwt/__init__.py +1 -0
  41. iki_protect/core/strategies/jwt/jwt_strategy.py +242 -0
  42. iki_protect/core/strategies/masking/__init__.py +1 -0
  43. iki_protect/core/strategies/masking/redact.py +19 -0
  44. iki_protect/core/strategies/masking/tokenize.py +26 -0
  45. iki_protect/core/strategies/signing/__init__.py +1 -0
  46. iki_protect/core/strategies/signing/asymmetric_sign.py +123 -0
  47. iki_protect/facade.py +524 -0
  48. iki_protect/version.py +1 -0
  49. ikiprotect-1.0.0.dist-info/METADATA +312 -0
  50. ikiprotect-1.0.0.dist-info/RECORD +53 -0
  51. ikiprotect-1.0.0.dist-info/WHEEL +4 -0
  52. ikiprotect-1.0.0.dist-info/entry_points.txt +2 -0
  53. ikiprotect-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,5 @@
1
+ """iki-protect package."""
2
+
3
+ from .facade import HPKE_AVAILABLE, IkiProtect
4
+
5
+ __all__ = ["IkiProtect", "HPKE_AVAILABLE"]
@@ -0,0 +1,4 @@
1
+ from iki_protect.cli.app import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1 @@
1
+ """CLI package."""
iki_protect/cli/app.py ADDED
@@ -0,0 +1,47 @@
1
+ """Root Typer application — the `ikiprotect` entrypoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich.console import Console
7
+
8
+ from iki_protect.cli.commands import decrypt as decrypt_cmd
9
+ from iki_protect.cli.commands import detect as detect_cmd
10
+ from iki_protect.cli.commands import encrypt as encrypt_cmd
11
+ from iki_protect.cli.commands import hash_cmd
12
+ from iki_protect.cli.commands import jwt_cmd
13
+ from iki_protect.cli.commands import keys as keys_cmd
14
+ from iki_protect.cli.commands import mask as mask_cmd
15
+ from iki_protect.cli.commands import sign as sign_cmd
16
+ from iki_protect.version import __version__
17
+
18
+ app = typer.Typer(
19
+ name="ikiprotect",
20
+ help="Portable content protection CLI: detect, mask, hash, encrypt, sign, JWT.",
21
+ no_args_is_help=True,
22
+ )
23
+ console = Console()
24
+
25
+ app.command("detect")(detect_cmd.detect)
26
+ app.command("mask")(mask_cmd.mask)
27
+ app.add_typer(hash_cmd.hash_app, name="hash")
28
+ app.command("encrypt")(encrypt_cmd.encrypt)
29
+ app.command("decrypt")(decrypt_cmd.decrypt)
30
+ app.command("sign")(sign_cmd.sign)
31
+ app.command("verify-signature")(sign_cmd.verify_signature)
32
+ app.command("generate-keypair")(keys_cmd.generate_keypair)
33
+ app.add_typer(jwt_cmd.jwt_app, name="jwt")
34
+
35
+
36
+ @app.callback()
37
+ def version_callback(version: bool = typer.Option(False, "--version")) -> None:
38
+ if version:
39
+ console.print(__version__)
40
+
41
+
42
+ def main() -> None:
43
+ app()
44
+
45
+
46
+ if __name__ == "__main__":
47
+ main()
@@ -0,0 +1,3 @@
1
+ from __future__ import annotations
2
+
3
+ from iki_protect.cli.commands import decrypt, detect, encrypt, hash_cmd, jwt_cmd, mask, sign # noqa: F401
@@ -0,0 +1,152 @@
1
+ """`ikiprotect decrypt` — reverse of `ikiprotect encrypt` for ONE file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ import typer
12
+ from rich.console import Console
13
+
14
+ from iki_protect.core.config import get_config_value
15
+ from iki_protect.core.errors import DecryptionError
16
+ from iki_protect.cli.facade import protect
17
+
18
+ console = Console()
19
+ PASSPHRASE_MAGIC = b"PHS1"
20
+
21
+
22
+ def _unwrap_passphrase_ciphertext(data: bytes) -> tuple[bytes, bytes]:
23
+ if not data.startswith(PASSPHRASE_MAGIC):
24
+ raise ValueError("Not a passphrase-wrapped ciphertext")
25
+ if len(data) < 5:
26
+ raise ValueError("Truncated passphrase wrapper")
27
+ salt_len = data[4]
28
+ if len(data) < 5 + salt_len:
29
+ raise ValueError("Truncated passphrase wrapper")
30
+ salt = data[5:5 + salt_len]
31
+ return salt, data[5 + salt_len:]
32
+
33
+
34
+ def _prompt_passphrase() -> str:
35
+ return typer.prompt("Passphrase", hide_input=True)
36
+
37
+
38
+ def _resolve_passphrase(passphrase: Optional[str]) -> Optional[str]:
39
+ if passphrase is not None:
40
+ return passphrase
41
+ return os.getenv("IKIPROTECT_PASSPHRASE")
42
+
43
+
44
+ def _derive_key(
45
+ key_ref: Optional[str],
46
+ passphrase: Optional[str],
47
+ kdf: str,
48
+ salt: Optional[str],
49
+ salt_bytes: bytes | None = None,
50
+ ) -> bytes:
51
+ if passphrase is not None:
52
+ if key_ref is not None:
53
+ raise typer.BadParameter(
54
+ "Use either --key-ref or --passphrase, not both")
55
+ if salt_bytes is None:
56
+ salt_bytes = bytes.fromhex(salt) if salt else b""
57
+ return protect.derive_passphrase_key(passphrase, kdf, salt_bytes)
58
+
59
+ if key_ref is None:
60
+ raise typer.BadParameter("--key-ref or --passphrase is required")
61
+ return protect.resolve_key(key_ref)
62
+
63
+
64
+ def decrypt(
65
+ input_file: Optional[typer.FileBinaryRead] = typer.Argument(
66
+ None, help="Read raw bytes from a file. Defaults to stdin."),
67
+ algo: str = typer.Option(
68
+ get_config_value("default_algo", "aes-256-gcm"),
69
+ "--algo",
70
+ help="Decryption algorithm or comma-separated algorithm chain for non-layered ciphertext.",
71
+ ),
72
+ key_ref: Optional[str] = typer.Option(
73
+ get_config_value("default_key_ref", None),
74
+ "--key-ref",
75
+ help="Key reference, like env:NAME or file:path.",
76
+ ),
77
+ passphrase: Optional[str] = typer.Option(
78
+ None,
79
+ "--passphrase",
80
+ help="Passphrase to derive the decryption key. Discouraged on the CLI; use IKI_PROTECT_PASSPHRASE or leave blank to prompt.",
81
+ ),
82
+ kdf: str = typer.Option(
83
+ "argon2id",
84
+ "--kdf",
85
+ help="KDF for passphrase-derived keys: sha256 | pbkdf2-sha256 | argon2id.",
86
+ ),
87
+ salt: Optional[str] = typer.Option(
88
+ None,
89
+ "--salt",
90
+ help="Hex-encoded salt for passphrase KDF. Omit if the ciphertext embeds the salt.",
91
+ ),
92
+ aad: Optional[str] = typer.Option(
93
+ None,
94
+ "--aad",
95
+ help="Additional authenticated data to validate during decryption.",
96
+ ),
97
+ input_format: str = typer.Option(
98
+ "raw",
99
+ "--input-format",
100
+ help="raw | base64",
101
+ ),
102
+ output: Optional[str] = typer.Option(None, "--output", "-o"),
103
+ ) -> None:
104
+ data = input_file.read() if input_file else sys.stdin.buffer.read()
105
+ if input_format not in {"raw", "base64"}:
106
+ raise typer.BadParameter("--input-format must be raw or base64")
107
+ if input_format == "base64":
108
+ data = base64.b64decode(data)
109
+
110
+ embedded_salt: bytes | None = None
111
+ if data.startswith(PASSPHRASE_MAGIC):
112
+ try:
113
+ embedded_salt, data = _unwrap_passphrase_ciphertext(data)
114
+ except ValueError as exc:
115
+ raise typer.BadParameter(str(exc)) from exc
116
+
117
+ if passphrase is None and key_ref is None:
118
+ passphrase = _prompt_passphrase()
119
+ passphrase = _resolve_passphrase(passphrase)
120
+
121
+ if salt is not None and embedded_salt is not None:
122
+ raise typer.BadParameter(
123
+ "Cannot provide --salt when the ciphertext already embeds salt"
124
+ )
125
+
126
+ key = _derive_key(key_ref, passphrase, kdf, salt, salt_bytes=embedded_salt)
127
+ aad_bytes = aad.encode("utf-8") if aad is not None else None
128
+
129
+ try:
130
+ if protect.is_envelope(data):
131
+ plaintext = protect.envelope_decrypt(key, data, aad=aad_bytes)
132
+ elif data.startswith(b"ILYR"):
133
+ plaintext = protect.layered_decrypt(key, data, aad=aad_bytes)
134
+ else:
135
+ try:
136
+ algos = protect.parse_algorithm_chain(algo, layers=1)
137
+ except ValueError as exc:
138
+ raise typer.BadParameter(str(exc)) from exc
139
+ if len(algos) > 1:
140
+ raise typer.BadParameter(
141
+ "Cannot decrypt raw ciphertext with multiple algorithms unless it is layered."
142
+ )
143
+ plaintext = protect.decrypt(
144
+ data, key, algorithm=algos[0], aad=aad_bytes)
145
+ except DecryptionError as exc:
146
+ raise typer.Exit(code=1) from exc
147
+
148
+ if output:
149
+ Path(output).write_bytes(plaintext)
150
+ console.print(f"Decrypted output written to {output}")
151
+ else:
152
+ sys.stdout.buffer.write(plaintext)
@@ -0,0 +1,123 @@
1
+ """`ikiprotect detect` — scan ONE string or ONE file for PII/secrets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from typing import Optional
8
+
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+
13
+ from iki_protect.cli.facade import protect
14
+ from pathlib import Path
15
+ import os
16
+ import fnmatch
17
+
18
+ console = Console()
19
+ err_console = Console(stderr=True)
20
+
21
+
22
+ def detect(
23
+ input_file: Optional[typer.FileText] = typer.Argument(
24
+ None, help="Read text from a file. Defaults to stdin."),
25
+ types: str = typer.Option("pii,secrets", "--types",
26
+ help="Comma-separated detector groups or names."),
27
+ output_format: str = typer.Option(
28
+ "text", "--format", help="text | json | ndjson"),
29
+ ) -> None:
30
+ text = input_file.read() if input_file else sys.stdin.read()
31
+ findings = sorted(protect.detect(text, types=types),
32
+ key=lambda f: (f.start, f.end))
33
+
34
+ if output_format == "json":
35
+ console.print_json(json.dumps([_dict(f) for f in findings]))
36
+ elif output_format == "ndjson":
37
+ for f in findings:
38
+ console.print_json(json.dumps(_dict(f)))
39
+ else:
40
+ table = Table(title="Findings")
41
+ table.add_column("Type")
42
+ table.add_column("Value")
43
+ table.add_column("Start")
44
+ table.add_column("End")
45
+ for f in findings:
46
+ table.add_row(f.type, f.hint(), str(f.start), str(f.end))
47
+ console.print(table)
48
+
49
+
50
+ def _dict(f) -> dict:
51
+ return {"type": f.type, "value_hint": f.hint(), "start": f.start, "end": f.end, "confidence": f.confidence}
52
+
53
+
54
+ def _load_ignore(path: Path) -> list[str]:
55
+ ignore_file = path / ".ikiprotectignore"
56
+ patterns: list[str] = []
57
+ if ignore_file.exists():
58
+ for line in ignore_file.read_text(encoding="utf-8").splitlines():
59
+ line = line.strip()
60
+ if not line or line.startswith("#"):
61
+ continue
62
+ patterns.append(line)
63
+ return patterns
64
+
65
+
66
+ def scan(
67
+ path: Path = typer.Argument(Path("."), help="File or directory to scan."),
68
+ types: str = typer.Option("pii,secrets", "--types",
69
+ help="Comma-separated detector groups or names."),
70
+ recursive: bool = typer.Option(True, "--recursive/--no-recursive"),
71
+ output_format: str = typer.Option(
72
+ "text", "--format", help="text | json | ndjson"),
73
+ fail_on: int = typer.Option(
74
+ 1, "--fail-on", help="Fail CI with exit code if findings >= this."),
75
+ ) -> None:
76
+ total_findings = []
77
+ patterns = _load_ignore(path if path.is_dir() else path.parent)
78
+
79
+ def _should_ignore(rel: str) -> bool:
80
+ for p in patterns:
81
+ if fnmatch.fnmatch(rel, p):
82
+ return True
83
+ return False
84
+
85
+ if path.is_file():
86
+ text = path.read_text(encoding="utf-8")
87
+ total_findings.extend(protect.detect(text, types=types))
88
+ else:
89
+ for root, dirs, files in os.walk(path):
90
+ relroot = os.path.relpath(root, path)
91
+ if relroot == ".":
92
+ relroot = ""
93
+ if _should_ignore(os.path.join(relroot, "")):
94
+ continue
95
+ for name in files:
96
+ rel = os.path.join(relroot, name) if relroot else name
97
+ if _should_ignore(rel):
98
+ continue
99
+ try:
100
+ text = Path(root, name).read_text(encoding="utf-8")
101
+ except Exception:
102
+ continue
103
+ total_findings.extend(protect.detect(text, types=types))
104
+
105
+ if output_format == "json":
106
+ console.print_json(json.dumps([_dict(f) for f in total_findings]))
107
+ elif output_format == "ndjson":
108
+ for f in total_findings:
109
+ console.print_json(json.dumps(_dict(f)))
110
+ else:
111
+ table = Table(title="Scan Findings")
112
+ table.add_column("File/Location")
113
+ table.add_column("Type")
114
+ table.add_column("Value")
115
+ table.add_column("Start")
116
+ table.add_column("End")
117
+ for f in total_findings:
118
+ table.add_row(getattr(f, "file", "<stdin>"), f.type,
119
+ f.hint(), str(f.start), str(f.end))
120
+ console.print(table)
121
+
122
+ if fail_on is not None and len(total_findings) >= fail_on:
123
+ raise typer.Exit(code=2)
@@ -0,0 +1,172 @@
1
+ """`ikiprotect encrypt` — reversible AEAD encryption of ONE file or stdin stream."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ import typer
12
+ from rich.console import Console
13
+
14
+ from iki_protect.core.config import get_config_value
15
+ from iki_protect.cli.facade import protect
16
+
17
+ console = Console()
18
+ PASSPHRASE_MAGIC = b"PHS1"
19
+
20
+
21
+ def _wrap_passphrase_ciphertext(ciphertext: bytes, salt: bytes) -> bytes:
22
+ if len(salt) > 255:
23
+ raise ValueError("Salt is too long for embedded metadata")
24
+ return PASSPHRASE_MAGIC + bytes([len(salt)]) + salt + ciphertext
25
+
26
+
27
+ def _prompt_passphrase() -> str:
28
+ return typer.prompt("Passphrase", hide_input=True)
29
+
30
+
31
+ def _resolve_passphrase(passphrase: Optional[str]) -> Optional[str]:
32
+ if passphrase is not None:
33
+ return passphrase
34
+ return os.getenv("IKIPROTECT_PASSPHRASE")
35
+
36
+
37
+ def _derive_key(
38
+ key_ref: Optional[str],
39
+ passphrase: Optional[str],
40
+ kdf: str,
41
+ salt: Optional[str],
42
+ salt_bytes: bytes | None = None,
43
+ ) -> bytes:
44
+ if passphrase is not None:
45
+ if key_ref is not None:
46
+ raise typer.BadParameter(
47
+ "Use either --key-ref or --passphrase, not both")
48
+ if salt_bytes is None:
49
+ salt_bytes = bytes.fromhex(salt) if salt else b""
50
+ return protect.derive_passphrase_key(passphrase, kdf, salt_bytes)
51
+
52
+ if key_ref is None:
53
+ raise typer.BadParameter("--key-ref or --passphrase is required")
54
+ return protect.resolve_key(key_ref)
55
+
56
+
57
+ def encrypt(
58
+ input_file: Optional[typer.FileBinaryRead] = typer.Argument(
59
+ None, help="Read raw bytes from a file. Defaults to stdin."),
60
+ algo: str = typer.Option(
61
+ get_config_value("default_algo", "aes-256-gcm"),
62
+ "--algo",
63
+ help="Encryption algorithm or comma-separated layered algorithm chain.",
64
+ ),
65
+ layers: int = typer.Option(
66
+ get_config_value("default_layers", 1),
67
+ "--layers",
68
+ help="Repeat a single algorithm this many times to create layered encryption.",
69
+ ),
70
+ key_ref: Optional[str] = typer.Option(
71
+ None,
72
+ "--key-ref",
73
+ help="Key reference, like env:NAME or file:path.",
74
+ ),
75
+ passphrase: Optional[str] = typer.Option(
76
+ None,
77
+ "--passphrase",
78
+ help="Passphrase to derive the encryption key. Discouraged on the CLI; use IKI_PROTECT_PASSPHRASE or leave blank to prompt.",
79
+ ),
80
+ kdf: str = typer.Option(
81
+ "argon2id",
82
+ "--kdf",
83
+ help="KDF for passphrase-derived keys: sha256 | pbkdf2-sha256 | argon2id.",
84
+ ),
85
+ salt: Optional[str] = typer.Option(
86
+ None,
87
+ "--salt",
88
+ help="Hex-encoded salt for passphrase KDF. Omit to embed a random salt in ciphertext.",
89
+ ),
90
+ aad: Optional[str] = typer.Option(
91
+ None,
92
+ "--aad",
93
+ help="Additional authenticated data to bind to ciphertext.",
94
+ ),
95
+ output_format: str = typer.Option(
96
+ "raw",
97
+ "--output-format",
98
+ help="raw | base64",
99
+ ),
100
+ envelope: bool = typer.Option(
101
+ False, "--envelope", help="Use DEK/KEK envelope encryption (wrap DEK under KEK)."),
102
+ output: Optional[str] = typer.Option(None, "--output", "-o"),
103
+ ) -> None:
104
+ data = input_file.read() if input_file else sys.stdin.buffer.read()
105
+ try:
106
+ algos = protect.parse_algorithm_chain(algo, layers=layers)
107
+ except ValueError as exc:
108
+ raise typer.BadParameter(str(exc)) from exc
109
+
110
+ if passphrase is None and key_ref is None:
111
+ passphrase = _prompt_passphrase()
112
+ passphrase = _resolve_passphrase(passphrase)
113
+
114
+ salt_bytes = None
115
+ if passphrase is not None:
116
+ if salt is None:
117
+ salt_bytes = os.urandom(16)
118
+ else:
119
+ salt_bytes = bytes.fromhex(salt)
120
+
121
+ key = _derive_key(key_ref, passphrase, kdf, salt, salt_bytes=salt_bytes)
122
+ aad_bytes = aad.encode("utf-8") if aad is not None else None
123
+
124
+ if envelope:
125
+ ciphertext = protect.envelope_encrypt(key, data, algos, aad=aad_bytes)
126
+ else:
127
+ if len(algos) == 1:
128
+ ciphertext = protect.encrypt(
129
+ data, key, algorithm=algos[0], aad=aad_bytes)
130
+ else:
131
+ ciphertext = protect.layered_encrypt(
132
+ key, data, algos, aad=aad_bytes)
133
+
134
+ if passphrase is not None:
135
+ ciphertext = _wrap_passphrase_ciphertext(ciphertext, salt_bytes or b"")
136
+
137
+ if output_format not in {"raw", "base64"}:
138
+ raise typer.BadParameter("--output-format must be raw or base64")
139
+
140
+ if output_format == "base64":
141
+ encoded = base64.b64encode(ciphertext).decode("ascii")
142
+ if output:
143
+ Path(output).write_text(encoded)
144
+ console.print(f"Encrypted output written to {output}")
145
+ else:
146
+ console.print(encoded)
147
+ return
148
+
149
+ if output:
150
+ Path(output).write_bytes(ciphertext)
151
+ console.print(f"Encrypted output written to {output}")
152
+ else:
153
+ sys.stdout.buffer.write(ciphertext)
154
+
155
+
156
+ def rewrap(
157
+ input_file: typer.FileBinaryRead = typer.Argument(
158
+ ..., help="Existing envelope file."),
159
+ old_key_ref: str = typer.Option(..., "--old-key-ref"),
160
+ new_key_ref: str = typer.Option(..., "--new-key-ref"),
161
+ output: Optional[str] = typer.Option(None, "--output", "-o"),
162
+ ) -> None:
163
+ data = input_file.read()
164
+
165
+ old_kek = protect.resolve_key(old_key_ref)
166
+ new_kek = protect.resolve_key(new_key_ref)
167
+ new_env = protect.rewrap_envelope(data, old_kek, new_kek)
168
+ if output:
169
+ Path(output).write_bytes(new_env)
170
+ console.print(f"Rewrapped envelope written to {output}")
171
+ else:
172
+ sys.stdout.buffer.write(new_env)