diff --git a/CHANGELOG.md b/CHANGELOG.md index 9227c66e..742ab0df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **MCP**: the HTTP bind-safety guard now keys on auth posture, not bind interface. The old `_assert_safe_http_bind` refused any non-loopback HTTP bind, which false-positived on the entire hosted deployment (a container binds `0.0.0.0` and is still private) and lived in the run path, so a caller building the ASGI app directly bypassed it. It is replaced by `McpSettings._enforce_bind_safety`, a cross-field validator at the settings boundary that every serving path inherits: the unauthenticated `local` profile still refuses a non-loopback HTTP bind unless `PIPEFY_MCP_ALLOW_INSECURE_HTTP_BIND` is set, while the `remote` profile (which always carries inbound auth) binds any host without a bind-host check. Loopback detection moves to `pipefy_infra.security.is_loopback_host`, covering all of `127.0.0.0/8` and `::1` rather than three hardcoded string literals. Closes #379. - **CLI / Plugin (Claude Code)**: the CLI install path now installs `pipefy-cli` from PyPI instead of a `git+…#subdirectory=packages/cli` reference with `--with` sibling pins. The `/pipefy:install` slash command runs `uv tool install --force pipefy-cli`, and the documented snippets (root `README.md`, `packages/cli/README.md`, `docs/MIGRATION.md`) use `uvx --from pipefy-cli pipefy …` / `uv tool install pipefy-cli`. PyPI resolves `pipefy` and `pipefy-auth` transitively, so the explicit `--with` flags are gone and installs no longer pay a git clone plus build. This matches the MCP server's PyPI install (#368); do not pass a global `--prerelease allow`, which would let transitive dependencies jump to their own pre-releases. `RELEASE.md` verification snippets move to PyPI accordingly, and the now-unused `latest` moving-tag release step is removed. Closes #234. +### Fixed + +- **Auth / CLI / MCP (Windows)**: `pipefy auth login` on Windows failed at the session-store step with `WinError 1783` ("The stub received bad data") from `CredWrite`. The stored session — access + refresh + id tokens plus metadata, UTF-16 encoded by `keyring` — exceeds Windows Credential Manager's `CRED_MAX_CREDENTIAL_BLOB_SIZE` (2560 bytes; the exact size varies with token claims), so the default `auto` backend can't store it. Added `PIPEFY_KEYCHAIN_BACKEND=dpapi` (`keychain_backend = "dpapi"` in TOML) — a DPAPI-encrypted file keyring (`keyrings.alt.Windows.EncryptedKeyring`) that is encrypted at rest and has no blob cap. Read from `config.toml` by both the CLI and MCP server, so a single per-user entry covers both processes. Opt-in; selecting `dpapi` off-Windows raises. The plaintext `file` backend is unchanged. + ## [0.3.0-alpha.1] - 2026-07-04 ### Added diff --git a/docs/cli/auth.md b/docs/cli/auth.md index d5380c7c..454d2e6f 100644 --- a/docs/cli/auth.md +++ b/docs/cli/auth.md @@ -91,7 +91,7 @@ PIPEFY_TOKEN="$MY_BEARER" uv run pipefy pipe list | `PIPEFY_SERVICE_ACCOUNT_CLIENT_SECRET` | Tier 3 | Service-account client secret. | | `PIPEFY_AUTH_CLIENT_ID` | Tier 4 | Public client id registered for the CLI. Defaults to `pipefy-cli`. | | `PIPEFY_DISABLE_STORED_SESSION` | Tier 4 | When `1` / `true`, the stored-session tier is skipped end-to-end: tier resolution never probes the keychain, and `pipefy auth login` / `pipefy auth logout` refuse with exit code 2. Use to avoid the keyring backend-discovery cost on cold start (headless Linux, CI) or to opt out of OS-keychain storage entirely. TOML key: `disable_stored_session`. | -| `PIPEFY_KEYCHAIN_BACKEND` | Tier 4 | Active `keyring` backend. `auto` (default) uses OS-keyring discovery; `file` swaps to a plaintext on-disk keyring under `~/.config/pipefy/keyring.cfg` (POSIX) / `%APPDATA%/pipefy/keyring.cfg` (Windows). TOML key: `keychain_backend`. | +| `PIPEFY_KEYCHAIN_BACKEND` | Tier 4 | Active `keyring` backend. `auto` (default) uses OS-keyring discovery; `file` swaps to a plaintext on-disk keyring under `~/.config/pipefy/keyring.cfg` (POSIX) / `%APPDATA%/pipefy/keyring.cfg` (Windows); `dpapi` (Windows only) uses a DPAPI-encrypted keyring that sidesteps the Credential Manager blob cap. TOML key: `keychain_backend`. | The service-account OAuth token URL (Tier 3) and the OIDC issuer URL (Tier 4) are **not** interchangeable: the first is `/oauth/token` for client-credentials, the second is the full OIDC discovery root for the user-login flow. @@ -216,7 +216,9 @@ Every `PIPEFY_*` env var is validated against a semantically meaningful regex at ### `Login succeeded but the session could not be stored in your keychain ()` -The login worked but `keyring` couldn't write the entry. On macOS / Windows this is rare. On headless Linux it usually means no Secret Service daemon is running — install `gnome-keyring` or `kwallet`, set `PIPEFY_KEYCHAIN_BACKEND=file` to use a plaintext file backend under the Pipefy config directory, or fall back to a static `PIPEFY_TOKEN`. +The login worked but `keyring` couldn't write the entry. On headless Linux it usually means no Secret Service daemon is running — install `gnome-keyring` or `kwallet`, set `PIPEFY_KEYCHAIN_BACKEND=file` to use a plaintext file backend under the Pipefy config directory, or fall back to a static `PIPEFY_TOKEN`. + +On **Windows** the failure is `WinError 1783` ("The stub received bad data") from `CredWrite`: the stored session (access + refresh + id tokens plus metadata, UTF-16 encoded) exceeds Windows Credential Manager's `CRED_MAX_CREDENTIAL_BLOB_SIZE` (2560 bytes) — the exact size varies with the token claims — so the default `auto` backend can't store it. Set `PIPEFY_KEYCHAIN_BACKEND=dpapi` (`keychain_backend = "dpapi"` in TOML) for a DPAPI-encrypted keyring with no size cap. When `PIPEFY_KEYCHAIN_BACKEND=file` is active the backend reports as `PlaintextKeyring` and the hint switches to a config-directory writability check (the file backend writes to `keyring.cfg` under the resolved config directory). @@ -269,4 +271,6 @@ Two env vars (mirrored as TOML keys) override the default behaviour: - `PIPEFY_KEYCHAIN_BACKEND=file` swaps the active backend to a plaintext on-disk keyring under `config_dir() / "keyring.cfg"` (`~/.config/pipefy/keyring.cfg` on POSIX, `%APPDATA%/pipefy/keyring.cfg` on Windows). Unblocks headless Linux without Secret Service. **The file is plaintext, not OS-secured**: anyone with read access to the file (including a co-tenant on a shared CI runner) reads the refresh token. Opt-in only. -These are independent: `PIPEFY_DISABLE_STORED_SESSION=1` takes precedence (the file backend is never read or written). `pipefy auth status` will reflect the active backend name regardless (`Keyring`, `PlaintextKeyring`, etc.). +- `PIPEFY_KEYCHAIN_BACKEND=dpapi` (**Windows only**) swaps to `keyrings.alt.Windows.EncryptedKeyring`, a DPAPI-encrypted file keyring. Unlike `file` it is encrypted at rest (per-user, via Windows DPAPI), and unlike the default Credential Manager backend it has no ~2560-byte blob cap — so it is the recommended fix when `pipefy auth login` fails with `WinError 1783`. Selecting it on a non-Windows platform raises. + +These are independent: `PIPEFY_DISABLE_STORED_SESSION=1` takes precedence (the file backend is never read or written). `pipefy auth status` will reflect the active backend name regardless (`Keyring`, `PlaintextKeyring`, `EncryptedKeyring`, etc.). diff --git a/docs/config.md b/docs/config.md index 67a5a4c6..d8981731 100644 --- a/docs/config.md +++ b/docs/config.md @@ -79,7 +79,7 @@ Credential variables reject leading and trailing whitespace; `PIPEFY_ORG_ID` (be | `PIPEFY_ORG_ID` | unset | Convenience: pins a default org for CLI and MCP tools that take an optional `org_id` argument. | | `PIPEFY_PORTAL_ORG_UUID` | unset | SDK portal integration tests only (`pytest -m integration -k portal`). Set in local `.env` to an organization **UUID** (or numeric org id string) where the active token has **`manage_portals`** (and usually `create_portal`). Never committed; runtime MCP/CLI do not read this. Many default orgs return `PERMISSION_DENIED` on portal writes — pick an org with portal admin scope. See [`mcp/tools/portal.md`](mcp/tools/portal.md#testing). | | `PIPEFY_DISABLE_STORED_SESSION` | `0` | Set to `1` (or `disable_stored_session = true` in TOML) to skip the keychain-backed stored-session tier entirely. `pipefy auth login` / `auth logout` refuse with exit code 2 when set. | -| `PIPEFY_KEYCHAIN_BACKEND` | `auto` | Set to `file` (or `keychain_backend = "file"` in TOML) to use a file-backed plaintext keyring under `~/.config/pipefy/keyring.cfg` (`%APPDATA%\pipefy\keyring.cfg` on Windows). Unblocks headless Linux and CI runners. Plaintext on disk; opt-in only. | +| `PIPEFY_KEYCHAIN_BACKEND` | `auto` | Set to `file` (or `keychain_backend = "file"` in TOML) to use a file-backed plaintext keyring under `~/.config/pipefy/keyring.cfg` (`%APPDATA%\pipefy\keyring.cfg` on Windows). Unblocks headless Linux and CI runners. Plaintext on disk; opt-in only. On Windows, set to `dpapi` (`keychain_backend = "dpapi"`) for a DPAPI-encrypted keyring that sidesteps the Credential Manager blob cap (2560 bytes) the Pipefy session exceeds — the default `auto` backend fails such a login with `WinError 1783`. Selecting `dpapi` off-Windows raises. | | `PIPEFY_ALLOW_INSECURE_URLS` | `false` | Disables the SSRF host check on URL variables. Local development only. | | `PIPEFY_CONFIG_FILE` | unset | Overrides the default `config.toml` path. See [File path](#file-path) above. | diff --git a/packages/auth/src/pipefy_auth/settings.py b/packages/auth/src/pipefy_auth/settings.py index 97b2071a..9ddd7f56 100644 --- a/packages/auth/src/pipefy_auth/settings.py +++ b/packages/auth/src/pipefy_auth/settings.py @@ -191,8 +191,8 @@ def _strip_str(cls, value: object) -> object: @field_validator("keychain_backend", mode="before") @classmethod def _normalize_keychain_backend(cls, value: object) -> object: - # ``keychain_backend`` is ``Literal["auto", "file"]``; copy-pasted env - # values like ``PIPEFY_KEYCHAIN_BACKEND=' AUTO '`` should normalize to + # ``keychain_backend`` is ``Literal["auto", "file", "dpapi"]``; copy-pasted + # env values like ``PIPEFY_KEYCHAIN_BACKEND=' AUTO '`` should normalize to # ``"auto"`` rather than fail Literal validation with a cryptic enum # error. Case is meaningful for credential fields (kept strict via # ``_strip_str``), so the lowering applies only here. @@ -301,7 +301,7 @@ def _normalize_keychain_backend(cls, value: object) -> object: ), ) - keychain_backend: Literal["auto", "file"] = Field( + keychain_backend: Literal["auto", "file", "dpapi"] = Field( default="auto", description=( "Active ``keyring`` backend (env: PIPEFY_KEYCHAIN_BACKEND). ``auto`` " @@ -309,7 +309,10 @@ def _normalize_keychain_backend(cls, value: object) -> object: "``keyrings.alt.file.PlaintextKeyring`` writing under " "``config_dir()/keyring.cfg``. The file backend stores credentials " "in plaintext on disk; opt-in only, intended for headless Linux " - "without Secret Service or for CI runners." + "without Secret Service or for CI runners. ``dpapi`` (Windows only) " + "swaps to a DPAPI-encrypted file keyring, sidestepping the Windows " + "Credential Manager blob cap (2560 bytes) that the Pipefy session " + "exceeds; selecting it off-Windows raises." ), ) diff --git a/packages/auth/src/pipefy_auth/storage.py b/packages/auth/src/pipefy_auth/storage.py index a4959437..f465878c 100644 --- a/packages/auth/src/pipefy_auth/storage.py +++ b/packages/auth/src/pipefy_auth/storage.py @@ -12,6 +12,7 @@ from __future__ import annotations +import sys import time from typing import Annotated, Any, Literal from urllib.parse import urlparse @@ -35,7 +36,7 @@ _TOKEN_FIELDS: frozenset[str] = frozenset(TokenResponse.model_fields.keys()) -def configure_keychain_backend(choice: Literal["auto", "file"]) -> None: +def configure_keychain_backend(choice: Literal["auto", "file", "dpapi"]) -> None: """Apply the requested keyring backend before any session read or write. Idempotent: safe to call multiple times; the second ``"file"`` call @@ -48,10 +49,33 @@ def configure_keychain_backend(choice: Literal["auto", "file"]) -> None: ``pipefy_infra.config.config_dir() / "keyring.cfg"``; the file stores credentials in plaintext on disk and is intended for headless Linux or CI runners where the OS keychain is unavailable. + ``"dpapi"`` (Windows only) swaps to + :class:`keyrings.alt.Windows.EncryptedKeyring`, a DPAPI-encrypted + file keyring. It sidesteps the Windows Credential Manager + ``CredWrite`` blob cap (``CRED_MAX_CREDENTIAL_BLOB_SIZE`` = 2560 + bytes). The session blob (access + refresh + id tokens plus + metadata, UTF-16 encoded) exceeds that in practice — its exact + size varies with the token claims — so the ``auto`` backend fails + the write with ``WinError 1783``. + + Raises: + RuntimeError: When ``"dpapi"`` is selected on a non-Windows platform. """ if choice == "auto": return import keyring + + if choice == "dpapi": + if sys.platform != "win32": + raise RuntimeError( + "keychain_backend='dpapi' is Windows-only (DPAPI via CRYPT32.DLL); " + "use 'auto' or 'file' on this platform." + ) + from keyrings.alt.Windows import EncryptedKeyring + + keyring.set_keyring(EncryptedKeyring()) + return + from keyrings.alt.file import PlaintextKeyring from pipefy_infra.config import config_dir diff --git a/packages/auth/tests/test_storage_backend.py b/packages/auth/tests/test_storage_backend.py index 2fa16027..a1461c70 100644 --- a/packages/auth/tests/test_storage_backend.py +++ b/packages/auth/tests/test_storage_backend.py @@ -5,6 +5,8 @@ from __future__ import annotations import json +import sys +import types from collections.abc import Iterator from pathlib import Path @@ -82,6 +84,36 @@ def test_file_backend_is_idempotent( assert first.file_path == second.file_path +@pytest.mark.unit +def test_dpapi_backend_rejected_off_windows( + _isolated_keyring: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``dpapi`` is Windows-only; selecting it elsewhere fails fast with guidance.""" + monkeypatch.setattr("pipefy_auth.storage.sys.platform", "linux") + with pytest.raises(RuntimeError, match="dpapi.*Windows"): + configure_keychain_backend("dpapi") + + +@pytest.mark.unit +def test_dpapi_backend_installs_encrypted_keyring_on_windows( + _isolated_keyring: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """On Windows, ``dpapi`` installs the DPAPI-encrypted keyring. + + ``keyrings.alt.Windows`` can't be imported off-Windows (it binds CRYPT32.DLL), + so stub the module to exercise the branch on any CI platform. + """ + monkeypatch.setattr("pipefy_auth.storage.sys.platform", "win32") + fake_module = types.ModuleType("keyrings.alt.Windows") + fake_module.EncryptedKeyring = InMemoryKeyring # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "keyrings.alt.Windows", fake_module) + + configure_keychain_backend("dpapi") + assert isinstance(keyring.get_keyring(), InMemoryKeyring) + + @pytest.mark.unit def test_file_backend_round_trip_writes_under_config_dir( _isolated_keyring: None, diff --git a/packages/cli/src/pipefy_cli/commands/auth.py b/packages/cli/src/pipefy_cli/commands/auth.py index 8dccf341..3603b766 100644 --- a/packages/cli/src/pipefy_cli/commands/auth.py +++ b/packages/cli/src/pipefy_cli/commands/auth.py @@ -203,9 +203,12 @@ def _open(url: str) -> bool: else: hint = ( "On headless Linux, ensure a Secret Service daemon " - "(gnome-keyring, kwallet) is running, set " - "PIPEFY_KEYCHAIN_BACKEND=file to use a plaintext file backend, " - "or use a static PIPEFY_TOKEN." + "(gnome-keyring, kwallet) is running, or set " + "PIPEFY_KEYCHAIN_BACKEND=file for a plaintext file backend. " + "On Windows, if the session is too large for Credential Manager " + "(WinError 1783), set PIPEFY_KEYCHAIN_BACKEND=dpapi for a " + "DPAPI-encrypted keyring with no size cap. " + "Or use a static PIPEFY_TOKEN." ) typer.echo( f"Login succeeded but the session could not be stored in your "