Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion docs/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_TOKEN>
ksef auth login-token
ksef auth status
ksef profile show
```
Expand Down Expand Up @@ -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 <wartosc>` powoduje ostrzezenie runtime (bez ujawniania sekretu).

Fallback kontekstu:
- `context_type` i `context_value` sa brane kolejno z: CLI option -> env (`KSEF_CONTEXT_*`) -> aktywny profil.
Expand Down Expand Up @@ -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 <wartosc>`, `--key-password <wartosc>`) powoduje ostrzezenie runtime (bez ujawniania sekretu).

Fallback kontekstu:
- `context_type` i `context_value` sa brane kolejno z: CLI option -> env (`KSEF_CONTEXT_*`) -> aktywny profil.
Expand Down Expand Up @@ -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:

Expand Down
110 changes: 104 additions & 6 deletions src/ksef_client/cli/commands/auth_cmd.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down
31 changes: 31 additions & 0 deletions tests/cli/integration/test_auth_login_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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",
Expand Down
85 changes: 85 additions & 0 deletions tests/cli/integration/test_auth_login_xades.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading