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
28 changes: 21 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,15 +74,17 @@ psi/
├── __init__.py Version string
├── provider.py SecretProvider Protocol + registry + parse_mapping()
├── models.py Generic models (SystemdScope, WorkloadConfig, etc.)
├── settings.py PsiSettings — YAML config with providers dict
├── settings.py PsiSettings — YAML config; providers dict + CacheConfig
├── secret.py Shell driver commands (store/lookup/delete/list)
├── serve.py HTTP server on Unix socket — dispatches to providers
├── setup.py Boot-time orchestration — provider-aware
├── serve.py HTTP server on Unix socket — dispatches to providers + cache
├── setup.py Boot-time orchestration — provider-aware, populates cache
├── cache.py Single-file encrypted cache (envelope + in-memory dict)
├── cache_backends.py TPM (systemd-creds + AES-GCM) and HSM (PKCS#11) cache backends
├── output.py TTY-aware output (Rich tables or JSON)
├── systemd.py Query systemd timer/unit status
├── systemd.py Query systemd timer/unit status + daemon_reload helper
├── unitgen.py Generators for systemd unit/quadlet file contents
├── installer.py Orchestrate systemd unit installation
├── cli.py Typer CLI — core commands + provider subcommands
├── cli.py Typer CLI — core + provider + cache subcommands
├── providers/
│ ├── __init__.py Provider factory (create_provider)
Expand Down Expand Up @@ -113,9 +115,15 @@ cli.py → settings.py, setup.py, secret.py, installer.py
providers/infisical/cli.py, providers/nitrokeyhsm/cli.py

serve.py → provider.py (open_all_providers, parse_mapping, close_all_providers)
serve.py → cache.py, cache_backends.py (Cache, make_backend — optional, degrades gracefully)
secret.py → provider.py (get_provider, parse_mapping)
setup.py → providers/infisical/ (InfisicalProvider, InfisicalConfig, resolve_auth)
setup.py → cache.py, cache_backends.py (eager cache population when enabled)
setup.py → systemd.py (daemon_reload helper, D-Bus-first fallback)
installer.py → systemd.py (daemon_reload helper)
provider.py → providers/__init__.py (create_provider)
cache.py → files.py (write_bytes_secure for atomic writes)
cache_backends.py → providers/nitrokeyhsm/ (crypto, pkcs11, pin — HSM backend reuses the provider)

providers/infisical/__init__.py → providers/infisical/api.py, models.py
providers/infisical/api.py → providers/infisical/auth.py, token.py
Expand Down Expand Up @@ -164,7 +172,7 @@ workloads:
myapp:
provider: infisical
unit: myapp.container
depends_on: [psi-secrets-setup.service]
depends_on: [psi-infisical-setup.service]
secrets:
- project: myproject
path: /myapp
Expand All @@ -173,7 +181,7 @@ workloads:
windmill-worker@:
provider: infisical
secrets:
- project: homelab
- project: myproject
path: /windmill
infisical:
provider: nitrokeyhsm
Expand All @@ -188,6 +196,12 @@ psi setup Discover secrets, register, generate drop
psi install Generate containers.conf.d/psi.conf
psi systemd install Generate systemd units

# Secret cache (optional)
psi cache init --backend {tpm,hsm} Provision cache encryption key
psi cache status [--verify] Show cache status (fast) or decrypt and count (slow)
psi cache refresh Re-run setup to repopulate the cache
psi cache invalidate <id> Drop an entry and persist

# Infisical provider
psi infisical login Test authentication
psi infisical env Fetch secrets as env vars
Expand Down
117 changes: 92 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,23 @@ no container spawned per lookup, just a fast HTTP request to a local socket.
Boot time:
psi serve → starts the lookup service on /run/psi/psi.sock
opens all configured providers (Infisical client, HSM session)
decrypts state_dir/cache.enc into memory (if cache enabled)
psi setup --provider nitrokeyhsm → registers HSM-backed workloads (instant, local-only)
psi setup --provider infisical → discovers secrets from Infisical, registers with Podman,
writes systemd drop-ins (retries if Infisical is starting)
writes systemd drop-ins, populates the encrypted cache

Container start:
Podman → Secret=myapp--DB_HOST,type=env,target=DB_HOST
→ shell driver calls: curl /run/psi/psi.sock/lookup/{secret_id}
→ PSI reads JSON mapping from state_dir
→ dispatches to the correct provider (infisical or nitrokeyhsm)
→ returns decrypted/fetched value to Podman → injected as env var
→ PSI checks the in-memory cache → hit → return plaintext (no I/O, no crypto)
→ miss → dispatch to provider, cache the result
→ returns value to Podman → injected as env var
```

The optional [secret cache](docs/secret-cache.md) lets lookups survive upstream provider
outages by decrypting a single encrypted file at `psi serve` startup and holding the dict
in memory. Disabled by default — see the cache doc for the threat model.

## Quick start

### 1. Install
Expand Down Expand Up @@ -110,7 +115,9 @@ Secrets are fetched from Infisical at container start time.

### Provider: Infisical

Fetches secrets from Infisical at lookup time. No secret values stored on disk.
Fetches secrets from Infisical at lookup time. By default no secret values are stored on disk —
only coordinate mappings. Enable the [secret cache](docs/secret-cache.md) if you want lookups
to survive Infisical outages (the cache is encrypted-at-rest with a TPM or HSM key).

```yaml
providers:
Expand Down Expand Up @@ -160,6 +167,41 @@ PIN resolution order: `$CREDENTIALS_DIRECTORY/hsm-pin` → config `pin` → `PSI

See the [Nitrokey HSM provider reference](docs/nitrokeyhsm-provider.md) for the full documentation.

### Secret cache

Opt-in single-file encrypted cache. With the cache enabled, `psi-infisical-setup` eagerly
fetches every configured secret value at boot and writes an encrypted bundle to
`state_dir/cache.enc`. `psi serve` decrypts it once at startup and serves lookups from
memory — upstream provider outages no longer stop containers from starting.

```yaml
cache:
enabled: true
backend: hsm # 'tpm' or 'hsm'. Required for the cache to populate.
```

The TPM backend uses a 32-byte AES-256 key sealed by `systemd-creds` to the host TPM2.
The HSM backend reuses the existing Nitrokey hybrid envelope (RSA-OAEP + AES-256-GCM),
unwrapping the AES key via PKCS#11 at `psi serve` startup.

```bash
# One-time provisioning (host)
sudo psi cache init --backend tpm # or --backend hsm

# Inspect — fast path, no crypto
sudo podman exec -i psi-secrets psi cache status

# Full verify — decrypts and counts entries
sudo podman exec -i psi-secrets psi cache status --verify

# Refresh the cache from providers (e.g. after rotating a secret)
sudo podman exec -i psi-secrets psi cache refresh
```

See the [secret cache reference](docs/secret-cache.md) for the threat model, envelope
format, deployment walkthroughs (native TPM, container TPM, container HSM), and
troubleshooting.

### Workloads

Each workload specifies which provider handles its secrets:
Expand Down Expand Up @@ -194,17 +236,17 @@ workloads:
windmill-server:
provider: infisical
secrets:
- project: homelab
- project: myproject
path: /windmill # shared secrets (DB_HOST, REDIS_URL, etc.)
- project: homelab
- project: myproject
path: /windmill/server # server-specific (MODE=server)

windmill-worker-1:
provider: infisical
secrets:
- project: homelab
- project: myproject
path: /windmill # same shared secrets
- project: homelab
- project: myproject
path: /windmill/worker # worker-specific (MODE=worker, NUM_WORKERS)
```

Expand All @@ -230,9 +272,9 @@ workloads:
provider: infisical
depends_on: [psi-infisical-setup.service]
secrets:
- project: homelab
- project: myproject
path: /windmill
- project: homelab
- project: myproject
path: /windmill/worker
```

Expand All @@ -256,17 +298,17 @@ workloads:
windmill-server:
provider: infisical
secrets:
- project: homelab
- project: myproject
path: /windmill
- project: homelab
- project: myproject
path: /windmill/server

windmill-worker@:
provider: infisical
secrets:
- project: homelab
- project: myproject
path: /windmill
- project: homelab
- project: myproject
path: /windmill/worker
```

Expand Down Expand Up @@ -446,6 +488,19 @@ psi install Generate containers.conf.d/psi.conf
psi systemd install Generate systemd units (--mode native or container)
```

### Secret cache

```
psi cache init --backend tpm Provision a TPM2-sealed AES key and empty cache.enc
psi cache init --backend hsm Write an empty cache.enc wrapped with the HSM public key
psi cache status Print backend, file metadata, and on-disk tag (fast)
psi cache status --verify Same, plus decrypt and report the entry count (slow)
psi cache refresh Re-run setup to repopulate the cache from providers
psi cache invalidate <id> Drop a single entry and persist the change
```

See the [secret cache reference](docs/secret-cache.md) for full documentation.

### Infisical provider

```
Expand Down Expand Up @@ -529,11 +584,23 @@ operators like `&&`, pipes, or redirection are not interpreted.
sudo psi systemd install --mode container --image ghcr.io/quickvm/psi:latest --enable
```

Or run the same command inside a one-shot psi container if you do not have a native `psi`
binary on the host. The container needs `/etc/containers/systemd` mounted read-write plus
the config, D-Bus, and podman sockets — see [secret-cache.md](docs/secret-cache.md) for
the exact invocation.

Generates per-provider setup units based on configured providers:
- `psi-secrets.container` — long-running lookup service
- `psi-{provider}-setup.container` — oneshot per provider (e.g. `psi-infisical-setup`, `psi-nitrokeyhsm-setup`)
- `psi-tls-renew.timer` + service — daily TLS renewal (if configured)

When the [secret cache](docs/secret-cache.md) is configured, the generator automatically
adds the HSM or TPM unseal wiring to both `psi-secrets.container` and the
`psi-{provider}-setup.container` files. For the HSM backend that means the pcscd socket
volume, `CREDENTIALS_DIRECTORY`, `LoadCredentialEncrypted=hsm-pin`, and an
`After=pcscd.service` ordering. For the TPM backend that means
`LoadCredentialEncrypted=psi-cache-key`.

The per-provider split allows independent systemd ordering. For example, Infisical
can depend on the HSM setup unit for its bootstrap secrets, while other services
depend on the Infisical setup unit:
Expand Down Expand Up @@ -565,8 +632,8 @@ installs quadlet files (`pcscd.container`, `pcscd-socket.volume`).

**Configure PSI serve to use pcscd:**

The PSI serve container needs the pcscd socket volume and a systemd ordering
dependency:
The PSI serve container needs the pcscd socket volume, the systemd credential for
the PIN, and an ordering dependency on `pcscd.service`:

```ini
# psi-secrets.container
Expand All @@ -575,19 +642,19 @@ After=network-online.target pcscd.service

[Container]
Volume=pcscd-socket:/run/pcscd:rw
```

**PIN delivery via TPM-sealed credential:**
Volume=/run/credentials/psi-secrets.service:/run/credentials:ro
Environment=CREDENTIALS_DIRECTORY=/run/credentials

```ini
[Service]
LoadCredentialEncrypted=hsm-pin

[Container]
Volume=/run/credentials/psi-secrets.service:/run/credentials:ro
Environment=CREDENTIALS_DIRECTORY=/run/credentials
```

`psi systemd install --mode container` emits all of this automatically when the
[secret cache](docs/secret-cache.md) is configured with `backend: hsm`, and also
propagates it to `psi-{provider}-setup.container` so the setup path can populate
the cache. Workloads using the nitrokeyhsm *provider* without the cache backend
still need the wiring done by hand (or via Butane).

See [Nitrokey HSM setup](#nitrokey-hsm-setup) for PIN encryption instructions.

**For Butane/Ignition deployments**, include the pcscd quadlet files in your
Expand Down
Loading
Loading