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.
- iki_protect/__init__.py +5 -0
- iki_protect/__main__.py +4 -0
- iki_protect/cli/__init__.py +1 -0
- iki_protect/cli/app.py +47 -0
- iki_protect/cli/commands/__init__.py +3 -0
- iki_protect/cli/commands/decrypt.py +152 -0
- iki_protect/cli/commands/detect.py +123 -0
- iki_protect/cli/commands/encrypt.py +172 -0
- iki_protect/cli/commands/hash_cmd.py +337 -0
- iki_protect/cli/commands/jwt_cmd.py +135 -0
- iki_protect/cli/commands/keys.py +127 -0
- iki_protect/cli/commands/mask.py +54 -0
- iki_protect/cli/commands/sign.py +56 -0
- iki_protect/cli/facade.py +5 -0
- iki_protect/content/__init__.py +1 -0
- iki_protect/content/base.py +34 -0
- iki_protect/content/config_file.py +46 -0
- iki_protect/content/plain_text.py +10 -0
- iki_protect/core/__init__.py +11 -0
- iki_protect/core/config.py +62 -0
- iki_protect/core/detectors/__init__.py +30 -0
- iki_protect/core/detectors/base.py +41 -0
- iki_protect/core/detectors/regex_detectors.py +233 -0
- iki_protect/core/detectors/registry.py +108 -0
- iki_protect/core/errors.py +26 -0
- iki_protect/core/keys/__init__.py +5 -0
- iki_protect/core/keys/kms.py +119 -0
- iki_protect/core/keys/manager.py +198 -0
- iki_protect/core/keys/sss.py +54 -0
- iki_protect/core/strategies/__init__.py +1 -0
- iki_protect/core/strategies/encryption/__init__.py +1 -0
- iki_protect/core/strategies/encryption/hpke.py +102 -0
- iki_protect/core/strategies/encryption/hybrid_kem.py +51 -0
- iki_protect/core/strategies/encryption/symmetric.py +419 -0
- iki_protect/core/strategies/hashing/__init__.py +1 -0
- iki_protect/core/strategies/hashing/envelope.py +154 -0
- iki_protect/core/strategies/hashing/fast_hash.py +101 -0
- iki_protect/core/strategies/hashing/kdf.py +25 -0
- iki_protect/core/strategies/hashing/slow_hash.py +119 -0
- iki_protect/core/strategies/jwt/__init__.py +1 -0
- iki_protect/core/strategies/jwt/jwt_strategy.py +242 -0
- iki_protect/core/strategies/masking/__init__.py +1 -0
- iki_protect/core/strategies/masking/redact.py +19 -0
- iki_protect/core/strategies/masking/tokenize.py +26 -0
- iki_protect/core/strategies/signing/__init__.py +1 -0
- iki_protect/core/strategies/signing/asymmetric_sign.py +123 -0
- iki_protect/facade.py +524 -0
- iki_protect/version.py +1 -0
- ikiprotect-1.0.0.dist-info/METADATA +312 -0
- ikiprotect-1.0.0.dist-info/RECORD +53 -0
- ikiprotect-1.0.0.dist-info/WHEEL +4 -0
- ikiprotect-1.0.0.dist-info/entry_points.txt +2 -0
- ikiprotect-1.0.0.dist-info/licenses/LICENSE +21 -0
iki_protect/__init__.py
ADDED
iki_protect/__main__.py
ADDED
|
@@ -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,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)
|