diff --git a/docs/cli/README.md b/docs/cli/README.md index b9fdd45..5403c8e 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -47,7 +47,7 @@ ksef init --non-interactive --name demo --env DEMO --context-type nip --context- 3. Logowanie tokenem + szybka weryfikacja sesji: ```bash -ksef auth login-token --ksef-token +ksef auth login-token ksef auth status ksef profile show ``` @@ -216,6 +216,8 @@ Options: Zrodlo tokenu: - `--ksef-token` ma najwyzszy priorytet, - gdy `--ksef-token` nie jest podany, CLI czyta `KSEF_TOKEN`. +- gdy `--ksef-token` nie jest podany i brak `KSEF_TOKEN`, CLI pyta o token ukrytym promptem (tryb interaktywny). +- podanie sekretu bezposrednio w `--ksef-token ` powoduje ostrzezenie runtime (bez ujawniania sekretu). Fallback kontekstu: - `context_type` i `context_value` sa brane kolejno z: CLI option -> env (`KSEF_CONTEXT_*`) -> aktywny profil. @@ -244,6 +246,8 @@ Walidacja: - uzyj dokladnie jednego zrodla certyfikatu: - `--pkcs12-path`, albo - para `--cert-pem` + `--key-pem`. +- gdy hasla (`--pkcs12-password`, `--key-password`) nie sa podane, CLI moze zapytac o nie ukrytym promptem (tryb interaktywny; puste wejscie = brak hasla). +- podanie hasla bezposrednio w opcji (`--pkcs12-password `, `--key-password `) powoduje ostrzezenie runtime (bez ujawniania sekretu). Fallback kontekstu: - `context_type` i `context_value` sa brane kolejno z: CLI option -> env (`KSEF_CONTEXT_*`) -> aktywny profil. @@ -440,6 +444,10 @@ Options: ## Bezpieczenstwo tokenow +- Rekomendacja dla sekretow auth: nie podawaj ich bezposrednio w argumentach CLI. + - interaktywnie: pomin opcje sekretu i wpisz wartosc w ukrytym prompcie, + - automatyzacja: uzyj zmiennych srodowiskowych (np. `KSEF_TOKEN`) albo managera sekretow. + - Domyslnie CLI wymaga systemowego keyringu do zapisu tokenow. - Gdy keyring jest niedostepny, mozliwy jest fallback szyfrowany przez klucz z env: diff --git a/src/ksef_client/cli/commands/auth_cmd.py b/src/ksef_client/cli/commands/auth_cmd.py index 0c34987..b721130 100644 --- a/src/ksef_client/cli/commands/auth_cmd.py +++ b/src/ksef_client/cli/commands/auth_cmd.py @@ -1,8 +1,10 @@ from __future__ import annotations import os +import sys import typer +from click.core import ParameterSource from ksef_client.exceptions import KsefApiError, KsefHttpError, KsefRateLimitError @@ -22,6 +24,59 @@ app = typer.Typer(help="Authenticate and manage tokens.") +def _is_interactive_terminal() -> bool: + return sys.stdin.isatty() and sys.stdout.isatty() + + +def _warn_if_secret_from_cli( + *, + ctx: typer.Context, + renderer, + command: str, + parameter_name: str, + option_name: str, + value: str | None, +) -> None: + if value is None: + return + try: + source = ctx.get_parameter_source(parameter_name) + except Exception: + return + if source != ParameterSource.COMMANDLINE: + return + renderer.info( + ( + f"WARNING: Secret provided via {option_name}. " + "This may be visible in shell history/process lists. " + f"Prefer omitting {option_name} for hidden prompt input." + ), + command=command, + ) + + +def _prompt_required_secret(secret: str | None, *, prompt: str) -> str | None: + if secret is not None: + return secret + if not _is_interactive_terminal(): + return None + return typer.prompt(prompt, hide_input=True) + + +def _prompt_optional_secret(secret: str | None, *, prompt: str) -> str | None: + if secret is not None: + return secret + if not _is_interactive_terminal(): + return None + value = typer.prompt( + f"{prompt} (leave empty if not set)", + hide_input=True, + default="", + show_default=False, + ) + return value or None + + def _render_error(ctx: typer.Context, command: str, exc: Exception) -> None: cli_ctx = require_context(ctx) renderer = get_renderer(cli_ctx) @@ -83,7 +138,10 @@ def login_token( ksef_token: str | None = typer.Option( None, "--ksef-token", - help="KSeF system token. Fallback: KSEF_TOKEN env var.", + help=( + "KSeF system token. If omitted, uses KSEF_TOKEN env var " + "or hidden prompt input in interactive mode." + ), ), context_type: str | None = typer.Option( None, @@ -109,12 +167,22 @@ def login_token( cli_ctx = require_context(ctx) renderer = get_renderer(cli_ctx) profile = profile_label(cli_ctx) + token = ksef_token if ksef_token is not None else os.getenv("KSEF_TOKEN") + token = _prompt_required_secret(token, prompt="KSeF token") + _warn_if_secret_from_cli( + ctx=ctx, + renderer=renderer, + command="auth.login-token", + parameter_name="ksef_token", + option_name="--ksef-token", + value=ksef_token, + ) try: profile = require_profile(cli_ctx) result = login_with_token( profile=profile, base_url=resolve_base_url(base_url or os.getenv("KSEF_BASE_URL"), profile=profile), - token=ksef_token or os.getenv("KSEF_TOKEN", ""), + token=token, context_type=context_type or os.getenv("KSEF_CONTEXT_TYPE", ""), context_value=context_value or os.getenv("KSEF_CONTEXT_VALUE", ""), poll_interval=poll_interval, @@ -140,12 +208,16 @@ def login_xades( help="Path to PKCS#12 file for XAdES authentication.", ), pkcs12_password: str | None = typer.Option( - None, "--pkcs12-password", help="Password for PKCS#12 file." + None, + "--pkcs12-password", + help="Password for PKCS#12 file. Omit to enter securely via hidden prompt.", ), cert_pem: str | None = typer.Option(None, "--cert-pem", help="Path to certificate PEM file."), key_pem: str | None = typer.Option(None, "--key-pem", help="Path to private key PEM file."), key_password: str | None = typer.Option( - None, "--key-password", help="Password for private key PEM." + None, + "--key-password", + help="Password for private key PEM. Omit to enter securely via hidden prompt.", ), context_type: str | None = typer.Option( None, @@ -176,6 +248,32 @@ def login_xades( cli_ctx = require_context(ctx) renderer = get_renderer(cli_ctx) profile = profile_label(cli_ctx) + use_pkcs12 = bool(pkcs12_path and pkcs12_path.strip()) + use_pem_pair = bool((cert_pem and cert_pem.strip()) or (key_pem and key_pem.strip())) + resolved_pkcs12_password = pkcs12_password + resolved_key_password = key_password + if use_pkcs12: + resolved_pkcs12_password = _prompt_optional_secret( + pkcs12_password, prompt="PKCS#12 password" + ) + if use_pem_pair: + resolved_key_password = _prompt_optional_secret(key_password, prompt="Private key password") + _warn_if_secret_from_cli( + ctx=ctx, + renderer=renderer, + command="auth.login-xades", + parameter_name="pkcs12_password", + option_name="--pkcs12-password", + value=pkcs12_password, + ) + _warn_if_secret_from_cli( + ctx=ctx, + renderer=renderer, + command="auth.login-xades", + parameter_name="key_password", + option_name="--key-password", + value=key_password, + ) try: profile = require_profile(cli_ctx) result = login_with_xades( @@ -184,10 +282,10 @@ def login_xades( context_type=context_type or os.getenv("KSEF_CONTEXT_TYPE", ""), context_value=context_value or os.getenv("KSEF_CONTEXT_VALUE", ""), pkcs12_path=pkcs12_path, - pkcs12_password=pkcs12_password, + pkcs12_password=resolved_pkcs12_password, cert_pem=cert_pem, key_pem=key_pem, - key_password=key_password, + key_password=resolved_key_password, subject_identifier_type=subject_identifier_type, poll_interval=poll_interval, max_attempts=max_attempts, diff --git a/tests/cli/integration/test_auth_login_token.py b/tests/cli/integration/test_auth_login_token.py index 7f36dc6..aada0a6 100644 --- a/tests/cli/integration/test_auth_login_token.py +++ b/tests/cli/integration/test_auth_login_token.py @@ -31,6 +31,7 @@ def test_auth_login_token_success(runner, monkeypatch) -> None: ) assert result.exit_code == 0 assert "Authentication successful." in result.stdout + assert "WARNING: Secret provided via --ksef-token." in result.stdout def test_auth_login_token_uses_env_vars_without_flags(runner, monkeypatch) -> None: @@ -54,6 +55,36 @@ def _fake_login(**kwargs): assert "Authentication successful." in result.stdout +def test_auth_login_token_prompts_hidden_when_missing_and_interactive(runner, monkeypatch) -> None: + seen: dict[str, object] = {} + + def _fake_login(**kwargs): + seen.update(kwargs) + return {"reference_number": "r-prompt", "saved": True} + + monkeypatch.setattr(auth_cmd, "login_with_token", _fake_login) + monkeypatch.setattr(auth_cmd, "_is_interactive_terminal", lambda: True) + + result = runner.invoke( + app, + [ + "auth", + "login-token", + "--context-type", + "nip", + "--context-value", + "5265877635", + ], + input="prompt-token\n", + ) + + assert result.exit_code == 0 + assert seen["token"] == "prompt-token" + assert "WARNING: Secret provided via --ksef-token." not in result.stdout + assert "prompt-token" not in result.stdout + assert "KSeF token:" in result.stdout + + def test_auth_login_token_validation_error(runner, monkeypatch) -> None: monkeypatch.setattr( "ksef_client.cli.commands.auth_cmd.login_with_token", diff --git a/tests/cli/integration/test_auth_login_xades.py b/tests/cli/integration/test_auth_login_xades.py index 94072ba..46ecead 100644 --- a/tests/cli/integration/test_auth_login_xades.py +++ b/tests/cli/integration/test_auth_login_xades.py @@ -34,6 +34,91 @@ def test_auth_login_xades_success(runner, monkeypatch) -> None: assert "Authentication successful." in result.stdout +def test_auth_login_xades_warns_for_cli_pkcs12_password(runner, monkeypatch) -> None: + monkeypatch.setattr( + auth_cmd, + "login_with_xades", + lambda **kwargs: {"reference_number": "x-p12", "saved": True}, + ) + result = runner.invoke( + app, + [ + "auth", + "login-xades", + "--pkcs12-path", + "cert.p12", + "--pkcs12-password", + "secret-pass", + "--context-type", + "nip", + "--context-value", + "5265877635", + ], + ) + assert result.exit_code == 0 + assert "WARNING: Secret provided via --pkcs12-password." in result.stdout + assert "secret-pass" not in result.stdout + + +def test_auth_login_xades_warns_for_cli_key_password(runner, monkeypatch) -> None: + monkeypatch.setattr( + auth_cmd, + "login_with_xades", + lambda **kwargs: {"reference_number": "x-key", "saved": True}, + ) + result = runner.invoke( + app, + [ + "auth", + "login-xades", + "--cert-pem", + "cert.pem", + "--key-pem", + "key.pem", + "--key-password", + "secret-key", + "--context-type", + "nip", + "--context-value", + "5265877635", + ], + ) + assert result.exit_code == 0 + assert "WARNING: Secret provided via --key-password." in result.stdout + assert "secret-key" not in result.stdout + + +def test_auth_login_xades_prompts_pkcs12_password_when_interactive(runner, monkeypatch) -> None: + seen: dict[str, object] = {} + + def _fake_login(**kwargs): + seen.update(kwargs) + return {"reference_number": "x-prompt", "saved": True} + + monkeypatch.setattr(auth_cmd, "login_with_xades", _fake_login) + monkeypatch.setattr(auth_cmd, "_is_interactive_terminal", lambda: True) + + result = runner.invoke( + app, + [ + "auth", + "login-xades", + "--pkcs12-path", + "cert.p12", + "--context-type", + "nip", + "--context-value", + "5265877635", + ], + input="prompt-pass\n", + ) + assert result.exit_code == 0 + assert seen["pkcs12_password"] == "prompt-pass" + assert "WARNING: Secret provided via --pkcs12-password." not in result.stdout + assert "prompt-pass" not in result.stdout + assert "PKCS#12 password (leave empty if not set):" in result.stdout + + def test_auth_login_xades_validation_error(runner, monkeypatch) -> None: monkeypatch.setattr( auth_cmd, diff --git a/tests/cli/unit/test_command_error_rendering.py b/tests/cli/unit/test_command_error_rendering.py index 725def1..dfcef72 100644 --- a/tests/cli/unit/test_command_error_rendering.py +++ b/tests/cli/unit/test_command_error_rendering.py @@ -1,8 +1,11 @@ from __future__ import annotations +from typing import cast + import pytest import typer from click import Command +from click.core import ParameterSource from ksef_client.cli.commands import ( auth_cmd, @@ -103,3 +106,60 @@ def test_render_error_cli_error_for_init_and_profile(module) -> None: with pytest.raises(typer.Exit) as exc: module._render_error(_ctx(), "cmd.test", CliError("bad", ExitCode.VALIDATION_ERROR, "fix")) assert exc.value.exit_code == int(ExitCode.VALIDATION_ERROR) + + +class _RecordingRenderer: + def __init__(self) -> None: + self.messages: list[tuple[str, str]] = [] + + def info(self, message: str, *, command: str) -> None: + self.messages.append((message, command)) + + +def test_auth_warn_if_secret_from_cli_ignores_parameter_source_errors() -> None: + class _FailingSourceContext: + @staticmethod + def get_parameter_source(_name: str): + raise RuntimeError("missing") + + renderer = _RecordingRenderer() + auth_cmd._warn_if_secret_from_cli( + ctx=cast(typer.Context, _FailingSourceContext()), + renderer=renderer, + command="auth.login-token", + parameter_name="ksef_token", + option_name="--ksef-token", + value="secret", + ) + assert renderer.messages == [] + + +def test_auth_warn_if_secret_from_cli_warns_only_for_commandline() -> None: + class _Context: + def __init__(self, source: ParameterSource) -> None: + self.source = source + + def get_parameter_source(self, _name: str) -> ParameterSource: + return self.source + + renderer = _RecordingRenderer() + auth_cmd._warn_if_secret_from_cli( + ctx=cast(typer.Context, _Context(ParameterSource.DEFAULT)), + renderer=renderer, + command="auth.login-token", + parameter_name="ksef_token", + option_name="--ksef-token", + value="secret", + ) + assert renderer.messages == [] + + auth_cmd._warn_if_secret_from_cli( + ctx=cast(typer.Context, _Context(ParameterSource.COMMANDLINE)), + renderer=renderer, + command="auth.login-token", + parameter_name="ksef_token", + option_name="--ksef-token", + value="secret", + ) + assert len(renderer.messages) == 1 + assert "--ksef-token" in renderer.messages[0][0]