diff --git a/.gitignore b/.gitignore
index 30ce0ad..1bf3391 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,6 +32,9 @@ Thumbs.db
__pycache__/
*.pyc
+# Node
+node_modules/
+
# Local agent guidance (private)
/AGENTS.md
/CLAUDE.md
diff --git a/README.md b/README.md
index c712b29..f63f534 100644
--- a/README.md
+++ b/README.md
@@ -1,940 +1,166 @@
-
+
- Phoenix: Attested secrets for agents. Reference-first by design.
- Like a phoenix, access is short-lived by design—attested, scoped, and reborn only when needed.
- Mission: use references first and minimize raw secret exposure.
+ Context-safe secrets management for AI agents.
+ Agents see references. Never raw values.
-[](https://github.com/phoenixsec/phoenix/actions/workflows/ci.yml)
+
-### Branding assets
+---
-- Full logo: `docs/branding/phoenix-v0-w-name-H-E-unique.svg`
-- Dark-mode monochrome: `docs/branding/phoenix-monochrome-icon.jpg`
-- Color icon only: `docs/branding/phoenix-v0-color-icon-only.svg`
+# Phoenix Secrets
----
+Phoenix Secrets is a self-hosted secrets manager purpose-built for AI agent workflows.
-# Phoenix
+When a secret enters an LLM's context — through a prompt, tool response, or log — you are trusting the model provider's entire infrastructure with that value: logging, caching, training pipelines, retention policies. You have no visibility into that chain of custody and no way to revoke it after the fact.
-Phoenix is a single-binary secrets manager purpose-built for AI agent workflows. LLM agents are powerful but fundamentally untrusted — they can leak secrets through output, store them in context or memory, or be tricked into passing them to attacker-controlled tools. Phoenix is designed for reference-first secret workflows (`phoenix://...`) resolved through authenticated, attested, policy-checked API calls. Every access is audited.
+Phoenix keeps secrets out of model context entirely. That is what **context-safe** means: an agent can use a secret to do its job without the raw value ever appearing in prompts, tool responses, logs, or the model's context window.
-Phoenix is designed to be set up and operated by your AI agent. The security is real — AES-256-GCM envelope encryption, mutual TLS, attestation policies — but the complexity is absorbed by the agent, not by you. Your agent configures policies, issues certificates, and manages access. You store the secrets and verify the result.
+No other secrets tool treats this as a design goal. Traditional managers like Vault and SOPS assume the consumer is a trusted process. Framework secret-reference features (like OpenClaw's SecretRef) solve config hygiene — they keep values out of config files, but not out of model context. Phoenix provides the actual security layer underneath all of them.
-```
+```bash
# Store a secret
phoenix set myapp/api-key -v "sk-abc123" -d "OpenAI API key"
-# Run a command with secrets injected (reference-first workflow)
+# Run a command with secrets injected — context-safe, no raw values in agent context
phoenix exec --env OPENAI_KEY=phoenix://myapp/api-key -- python app.py
-# Optional manual/debug inspection (plaintext output)
+# Resolve a reference directly (plaintext output for manual/debug use)
phoenix resolve phoenix://myapp/api-key
```
---
-## Start Here
-
-- **New install / first setup:** continue with [Quick Start](#quick-start)
-- **Migrating from `.env` files:** follow [Migration Guide: `.env` to Phoenix](docs/migration-env-to-phoenix.md)
-- **Runnable integration examples:** see [examples/README.md](examples/README.md)
-
----
-
-## Reality check (current behavior)
-
-Phoenix supports reference-first secret handling, but it does **not** currently enforce
-"reference-only" usage for all clients. Any identity with read-capable access can still
-retrieve plaintext via:
+## Why Phoenix Secrets?
-- `phoenix get`
-- `phoenix resolve`
-- direct API calls (`GET /v1/secrets/*`, `POST /v1/resolve`)
+You know your setup is sketchy. Raw API keys in `.env` files, pasted into prompts, passed through scripts, scattered across agent configs. But secret management shouldn't become a full-time job, and enterprise platforms like Vault are overkill for a team of five.
-Use scoped ACLs, mTLS/attestation, and operational guardrails to minimize plaintext exposure.
+Phoenix is built for self-hosted, homelab, small-team, and internal deployments where you want real controls without the overhead. No database, no cloud account, no external service required — and your agent can help you set the whole thing up:
-## Execution model (quick deployment guidance)
+- **Context-safe delivery** — secrets stay out of agent prompts, tool output, and logs
+- **Encrypted storage** — AES-256-GCM envelope encryption with per-namespace keys
+- **Per-agent access control** — each agent only sees the paths it needs
+- **Sealed responses** — end-to-end encrypted delivery per agent, even on shared hosts
+- **Attestation and policy** — per-path requirements like mTLS, IP binding, and tool identity
+- **Audit trail** — every access, denial, and resolution attempt is logged (secret values are never written to the log)
+- **Reference-first workflows** — `phoenix://` URIs replace raw values in configs and scripts
+- **Exec credential stripping** — `phoenix exec` injects secrets and strips broker credentials from the child process
+- **Works with existing setups** — `phoenix import` pulls from `.env` files in one command; 1Password users can migrate secrets or broker reads at runtime without moving anything
+- **Agent-native integrations** — built-in MCP server for Claude Code / Claude Desktop, Python/Go/TypeScript SDKs, OpenClaw exec backend, direct HTTP API
+- **Emergency offline access** — break-glass secret retrieval directly from disk when the server is down
-- **Requires local `phoenix` binary:** `phoenix exec`, MCP **stdio** mode, and normal CLI commands.
-- **Can be remote API only:** direct HTTP integrations with `/v1/*` endpoints.
-- **Container pattern:** run `phoenix exec --output-env ...` in an init container (or pre-start step) and consume the generated env file in the app container.
+Two binaries (`phoenix` client + `phoenix-server`), single codebase, no external runtime dependencies.
----
-
-## Why Phoenix?
-
-Existing secrets managers (Vault, Infisical, Doppler, SOPS) assume the consumer is a trusted process. The app fetches a secret, holds it in memory, and the developer is trusted not to leak it. That model breaks with AI agents.
+**Sealed responses** use per-agent key pairs (X25519) so the server encrypts each response to a specific agent. Even on a shared host, one agent cannot read another's secrets. In MCP mode, tool output contains opaque tokens instead of plaintext — the raw value never enters the model's context. See [Sealed Responses](docs/sealed-responses.md).
-Secret *reference* features in agent frameworks (like OpenClaw's `SecretRef`) are a step forward — they move plaintext out of config files. But they're config hygiene tools, not security infrastructure. They have no encryption at rest, no access control between agents, no attestation, and no audit trail of who accessed what at runtime.
+Because "just trust the model stack with your secrets" is not a serious security plan.
-Phoenix is the layer underneath. It provides the actual vault, the identity verification, the policy engine, and the audit trail. Agent frameworks can use Phoenix as their secret backend through the exec provider pattern, MCP, or direct API calls.
-
-| | Config ref tools | Phoenix |
+| | `.env` / framework refs | Phoenix Secrets |
|---|---|---|
| Plaintext out of configs | Yes | Yes |
+| Plaintext out of model context | No | Yes — sealed responses + exec isolation |
| Encrypted at rest | No | AES-256-GCM envelope encryption |
| Per-agent access control | No | Glob-pattern ACL per agent |
-| Identity attestation | No | mTLS + source IP + cert fingerprint |
+| Identity attestation | No | mTLS + policy + optional local attestation |
| Runtime audit trail | No | Every access logged with identity |
| Credential stripping | No | `phoenix exec` strips broker creds |
| Key rotation | No | Two-phase commit KEK rotation |
-| Certificate infrastructure | No | Built-in CA with CRL revocation |
-
----
-
-## Features
-
-**Encrypted Storage** — AES-256-GCM envelope encryption with per-namespace data encryption keys (DEKs) wrapped by a master key (KEK). Secrets are never stored in plaintext.
-
-**Mutual TLS** — Built-in certificate authority issues 90-day agent certificates. Agents authenticate with client certs instead of (or in addition to) bearer tokens. CRL-based revocation supported.
-
-**Access Control** — Per-agent permissions with glob-pattern path matching. Agents only see what they're allowed to see.
-
-**Configurable Attestation** — Each secret path can have its own attestation requirements, from no attestation to full lockdown. You choose the security level that fits each secret:
-
-| Level | What it proves | Config |
-|---|---|---|
-| None | Agent has valid credentials | *(default)* |
-| Network-bound | Request comes from expected host | `source_ip` |
-| Identity-pinned | Specific certificate required | `cert_fingerprint` |
-| mTLS-only | Cryptographic identity, no tokens | `require_mtls` + `deny_bearer` |
-
-Levels compose — a production database password can require mTLS from a specific IP with a pinned cert, while a staging API key just needs a valid token.
-
-**Reference Resolution** — `phoenix://namespace/secret` references are opaque tokens safe to store in configs, prompts, and logs. Resolved only through the authenticated API.
-
-**Exec Wrapper** — `phoenix exec` resolves references and injects them as environment variables, then replaces itself with your command. Broker credentials are stripped from the child process — the child can only use the secrets you explicitly map.
-
-**Crash-Safe Key Rotation** — Master key rotation uses two-phase commit with pre-rotation backups. If anything fails mid-rotation, the system recovers automatically.
-
-**Full Audit Trail** — Every secret access, denial, and resolution attempt is logged with agent identity, action, path, IP, and reason. Audit logs are append-only JSON Lines. Secret values are never logged.
-
-**Agent-Native Integration** — MCP server mode for Claude Code/Desktop. Works as an OpenClaw exec backend. SDK clients for Go, Python, and TypeScript. Your agent framework talks to Phoenix natively.
-
-**Minimal Dependencies** — Single binary, no external services. Only `golang.org/x/crypto` (Argon2id) and `golang.org/x/term` (TTY passphrase input) — Go team semi-stdlib.
-
----
-
-## Additional Docs
-
-- [Public Roadmap](docs/roadmap.md)
-- [Threat Model](docs/threat-model.md)
-- [Migration Guide: `.env` to Phoenix](docs/migration-env-to-phoenix.md)
-- [Admin Token Lifecycle](docs/admin-token-lifecycle.md)
-- [API Reference Index](docs/api-reference-index.md)
-- [Reference-Only Enforcement Design (WIP)](docs/reference-only-enforcement-design.md)
-- [Release Runbook](docs/release-runbook.md)
-- [Runnable Examples](examples/README.md)
---
## Quick Start
-### Requirements
-
-- Go 1.25+ (no external dependencies)
-- Linux, macOS, or Windows
-
-### Install from GitHub Releases
+### 1. Install
```bash
-curl -fsSL https://raw.githubusercontent.com/phoenixsec/phoenix/main/scripts/install.sh | sh
-```
+curl -fsSL https://raw.githubusercontent.com/phoenixsec-dev/phoenix/main/scripts/install.sh | sh
-Options:
-- `PHOENIX_VERSION=vX.Y.Z` pin a specific release
-- `INSTALL_DIR=/custom/bin` choose install path
-
-### Build
-
-```bash
-git clone https://github.com/phoenixsec/phoenix.git
-cd phoenix
+# Or build from source:
+git clone https://github.com/phoenixsec-dev/phoenix.git && cd phoenix
go build -o bin/ ./cmd/...
```
-This produces two binaries:
-- `bin/phoenix` — CLI client
-- `bin/phoenix-server` — API server
-
-### Initialize
+### 2. Initialize
```bash
-./bin/phoenix-server --init /data/phoenix
+phoenix-server --init /data/phoenix
```
-This generates:
-- Master encryption key (`master.key`, mode `0600`)
-- Admin bearer token — **save this, it is only shown once**
-- Internal CA certificate and key
-- Server TLS certificate (SANs: `localhost`, `127.0.0.1`)
-- Default configuration file
-
-> **Admin token handling (important):**
-> 1. Store it immediately in a password manager or secure vault (not shell history, chat logs, or committed files).
-> 2. Use it as a bootstrap credential only: create scoped agent identities/tokens and mTLS certs for regular workloads.
-> 3. Remove it from your shell env after bootstrap (`unset PHOENIX_TOKEN`).
-> 4. Review the full lifecycle guide: [docs/admin-token-lifecycle.md](docs/admin-token-lifecycle.md).
-
-> **Deploying on a LAN?** The default server cert only covers localhost.
-> After init, edit `config.json` to set `server.listen` to your host IP,
-> then re-issue a server cert that includes it:
->
-> ```bash
-> phoenix cert issue phoenix-server -o .
-> ```
->
-> Alternatively, put Phoenix behind a reverse proxy that terminates TLS.
-
-**File permissions:** The init command creates files with secure defaults, but verify:
-```bash
-chmod 700 /data/phoenix
-chmod 600 /data/phoenix/master.key /data/phoenix/ca.key /data/phoenix/server.key
-```
+Save the printed admin token immediately — it is only shown once.
-### Start the Server
+### 3. Start the server
```bash
-./bin/phoenix-server --config /data/phoenix/config.json
-```
-
-```
-Phoenix server starting on 127.0.0.1:9090
- Store: /data/phoenix/store.json (0 secrets)
- Key provider: file
- ACL: /data/phoenix/acl.json (1 agents)
- Audit: /data/phoenix/audit.log
- mTLS: enabled (require=false)
- Bearer: true
+phoenix-server --config /data/phoenix/config.json
```
-> **Binding to all interfaces:** The default listen address is `127.0.0.1:9090` (loopback only).
-> To accept connections from other hosts, set `server.listen` to `0.0.0.0:9090` in your config file.
-> Only do this with mTLS enabled or behind a firewall — the server should not be exposed without authentication on a public network.
-
-### Store and Retrieve Secrets
+### 4. Store and use a secret
```bash
export PHOENIX_SERVER="https://localhost:9090"
export PHOENIX_TOKEN=""
export PHOENIX_CA_CERT="/data/phoenix/ca.crt"
-# Store secrets
-phoenix set myapp/db-password -v "hunter2" -d "Production database password"
-phoenix set myapp/api-key -v "sk-live-abc123" -d "Stripe API key"
-
-# Read a secret
-phoenix get myapp/db-password
-
-# List secrets
-phoenix list myapp/
-
-# Delete a secret
-phoenix delete myapp/old-key
-```
-
----
-
-## Agent Authentication
-
-### Bearer Tokens
-
-The simplest method. Create an agent with specific permissions:
-
-```bash
-phoenix agent create deployer \
- -t "deploy-token-abc" \
- --acl "myapp/*:read;staging/*:read,write"
-```
-
-The `deployer` agent can read anything under `myapp/` and read/write under `staging/`.
-
-### Mutual TLS (Recommended)
-
-Issue a client certificate for an agent:
-
-```bash
-phoenix cert issue deployer -o /etc/phoenix/certs/
-```
-
-This writes `deployer.crt`, `deployer.key`, and `ca.crt`. Configure the agent:
-
-```bash
-export PHOENIX_SERVER="https://phoenix.home:9090"
-export PHOENIX_CA_CERT="/etc/phoenix/certs/ca.crt"
-export PHOENIX_CLIENT_CERT="/etc/phoenix/certs/deployer.crt"
-export PHOENIX_CLIENT_KEY="/etc/phoenix/certs/deployer.key"
-
-phoenix get myapp/db-password
-```
-
-No bearer token needed — the certificate CN identifies the agent.
-
----
-
-## Reference Resolution
-
-To minimize secret exposure in agent context, use `phoenix://` references instead of plaintext in configs/prompts:
-
-```bash
-# Resolve a single reference (outputs raw value, pipeable)
-phoenix resolve phoenix://myapp/db-password
-hunter2
-
-# Resolve multiple references
-phoenix resolve phoenix://myapp/db-password phoenix://myapp/api-key
-phoenix://myapp/db-password hunter2
-phoenix://myapp/api-key sk-live-abc123
-```
-
-### Batch Resolution API
-
-```bash
-curl -X POST https://localhost:9090/v1/resolve \
- -H "Authorization: Bearer $TOKEN" \
- -d '{"refs": ["phoenix://myapp/db-password", "phoenix://myapp/api-key"]}'
+phoenix set myapp/api-key -v "sk-live-abc123" -d "API key"
+phoenix exec --env API_KEY=phoenix://myapp/api-key -- env | grep API_KEY
```
-```json
-{
- "values": {
- "phoenix://myapp/db-password": "hunter2",
- "phoenix://myapp/api-key": "sk-live-abc123"
- }
-}
-```
-
-Partial failures return per-ref errors without blocking successful resolutions.
+For the full first-run walkthrough, see [Getting Started](docs/getting-started.md).
---
-## Exec Wrapper
-
-Run commands with secrets injected as environment variables:
-
-```bash
-phoenix exec \
- --env DB_PASSWORD=phoenix://myapp/db-password \
- --env STRIPE_KEY=phoenix://myapp/api-key \
- -- node server.js
-```
-
-Phoenix resolves all references, strips its own credentials (`PHOENIX_TOKEN`, `PHOENIX_CLIENT_CERT`, etc.) from the child environment, and `exec`s into the command. The child process:
-- Gets `DB_PASSWORD=hunter2` and `STRIPE_KEY=sk-live-abc123` in its environment
-- Cannot call Phoenix directly (no broker credentials)
-- Never sees `phoenix://` references
-
-This enforces least-privilege: the child only gets the specific secrets you map.
-
-Additional exec flags:
-- `--timeout 5s` — fail if secret resolution exceeds the given duration
-- `--mask-env` — also strip any inherited env vars whose values contain `phoenix://` references
-- `--output-env ` — write resolved env to a file instead of exec'ing (for Docker init-container patterns)
+## Documentation
-**Secure secret input** — avoid pasting secrets in command arguments:
-
-```bash
-echo "my-secret" | phoenix set myapp/api-key --value-stdin
-```
-
----
-
-## Attestation Policies
-
-Attestation policies let you configure security requirements per secret path. Every path can have different requirements — from completely open to fully locked down.
-
-### Example: Graduated Security
-
-```json
-{
- "attestation": {
- "dev/*": {
- "require_mtls": false,
- "deny_bearer": false
- },
- "staging/*": {
- "require_mtls": true,
- "source_ip": ["192.168.0.0/24"]
- },
- "production/*": {
- "require_mtls": true,
- "deny_bearer": true,
- "source_ip": ["192.168.0.110", "192.168.0.115"],
- "cert_fingerprint": "sha256:A1B2C3..."
- }
- }
-}
-```
-
-- `dev/*` — any valid credential works
-- `staging/*` — must use mTLS, must be on the local network
-- `production/*` — must use a specific certificate, from a specific IP, no bearer tokens
-
-Add to your server config:
-
-```json
-{
- "policy": {
- "path": "/data/phoenix/policy.json"
- }
-}
-```
-
-Attestation is enforced on secret reads, reference resolution, and path listing.
-
-### Test Policies from the CLI
-
-```bash
-export PHOENIX_POLICY="/data/phoenix/policy.json"
-
-# Show requirements for a path
-phoenix policy show production/db-password
-
-# Dry-run an attestation check
-phoenix policy test --agent deployer --ip 192.168.0.110 production/db-password
-```
-
-`phoenix policy test` is a local approximation helper. It is useful for
-policy sanity checks, but it does not fully simulate live cryptographic proof
-paths (mTLS handshakes, nonce freshness/replay state, signed payload validation).
-
----
-
-## Agent Framework Integration
-
-### MCP Server (Claude Code / Claude Desktop)
-
-Phoenix includes a built-in MCP server. Agents resolve secrets through tool calls, which can keep values out of prompt text in many workflows.
-
-**Transport options:**
-- `phoenix mcp-server` → stdio JSON-RPC (best for local Claude Code/Desktop setup)
-- `phoenix mcp-server --http :8080 --mcp-token ` → Streamable HTTP on `/mcp` (best for remote/shared MCP clients)
-
-```json
-{
- "mcpServers": {
- "phoenix": {
- "command": "phoenix",
- "args": ["mcp-server"],
- "env": {
- "PHOENIX_SERVER": "https://phoenix:9090",
- "PHOENIX_TOKEN": "..."
- }
- }
- }
-}
-```
-
-Streamable HTTP mode example:
-
-```bash
-export PHOENIX_SERVER="https://phoenix:9090"
-export PHOENIX_TOKEN=""
-export PHOENIX_MCP_TOKEN=""
-phoenix mcp-server --http 127.0.0.1:8080
-```
-
-Then point your MCP client to `http://127.0.0.1:8080/mcp` with:
-- `Authorization: Bearer `
-- `Mcp-Session-Id: ` after `initialize`
-
-The agent can list available secrets, resolve references, and read values — all through the authenticated, policy-checked API. MCP tool calls include tool identity headers (`X-Phoenix-Tool`), enabling `allowed_tools`/`deny_tools` attestation policies to control which MCP tools can access which secrets.
-
-> **Security note:** `phoenix_get` and `phoenix_resolve` return plaintext secret values in the MCP tool response. This may keep values out of prompt text, but the tool output is still visible to the MCP client process. Scope production tokens and ACLs tightly — grant agents only the minimum paths they need.
-
-### Claude Code Skill (SKILL.md)
-
-Phoenix also includes a reusable skill definition at `phoenix-skill/SKILL.md`.
-Use it when you want command-driven integration without running MCP server mode.
-
-The skill includes:
-- operational commands (`set/get/list/resolve/status/policy/audit`)
-- safety guardrails (avoid pasting secrets in chat, prefer `--value-stdin`)
-- a runbook for adding secrets and granting scoped agent access
-
-### OpenClaw Exec Backend
-
-Phoenix works as an OpenClaw external secrets provider through the `exec` backend. OpenClaw's `SecretRef` system handles config-level reference mapping. Phoenix handles encryption, access control, attestation, and audit. Each layer does what it's good at.
-
-**1. Configure the exec provider** in your OpenClaw config:
-
-```json
-{
- "secrets": {
- "providers": {
- "phoenix": {
- "type": "exec",
- "command": "phoenix",
- "args": ["resolve"]
- }
- }
- }
-}
-```
-
-**2. Use SecretRefs backed by Phoenix** in your gateway config:
-
-```yaml
-api_keys:
- openai: ${{ secrets.phoenix.phoenix://myapp/openai-key }}
- anthropic: ${{ secrets.phoenix.phoenix://myapp/anthropic-key }}
-```
-
-**3. Set Phoenix credentials** for the OpenClaw process:
-
-```bash
-export PHOENIX_SERVER=https://phoenix:9090
-export PHOENIX_TOKEN=openclaw-agent-token
-# Or use mTLS:
-export PHOENIX_CA_CERT=/etc/phoenix/ca.crt
-export PHOENIX_CLIENT_CERT=/etc/phoenix/openclaw.crt
-export PHOENIX_CLIENT_KEY=/etc/phoenix/openclaw.key
-```
-
-**4. Validate** before deploying:
-
-```bash
-# Dry-run: verify all refs are accessible without exposing values
-phoenix verify --dry-run gateway-config.yaml
-
-# Check that the OpenClaw agent has the right permissions
-phoenix policy test --agent openclaw --ip 10.0.0.5 myapp/openai-key
-```
-
-**What each layer does:**
-
-| Concern | OpenClaw | Phoenix |
-|---------|----------|---------|
-| Config reference mapping | SecretRef extraction and resolution | - |
-| Encryption at rest | - | AES-256-GCM envelope encryption |
-| Access control | - | Per-agent ACLs with glob matching |
-| Runtime attestation | - | mTLS, IP-binding, process identity |
-| Audit trail | - | Append-only JSON Lines audit log |
-| Credential rotation | - | `phoenix rotate-master`, cert reissue |
-
-### Python SDK
-
-```bash
-pip install phoenix-secrets # or: pip install sdk/python
-```
-
-```python
-from phoenix_secrets import PhoenixClient
-
-client = PhoenixClient() # reads PHOENIX_SERVER + PHOENIX_TOKEN from env
-
-# Resolve a single secret
-api_key = client.resolve("phoenix://myapp/api-key")
-
-# Batch resolve
-result = client.resolve_batch([
- "phoenix://myapp/openai-key",
- "phoenix://myapp/db-password",
-])
-for ref, value in result["values"].items():
- print(f"{ref} = {value[:4]}...")
-
-# Dry-run verify (no plaintext returned)
-check = client.verify(["phoenix://myapp/api-key"])
-
-# Health check
-client.health() # {"status": "ok"}
-```
-
-### Direct API
-
-For any agent framework, the HTTP API is straightforward:
-
-```bash
-# Resolve references
-curl -X POST https://phoenix:9090/v1/resolve \
- -H "Authorization: Bearer $TOKEN" \
- -d '{"refs": ["phoenix://myapp/api-key"]}'
-
-# Read a secret
-curl https://phoenix:9090/v1/secrets/myapp/api-key \
- -H "Authorization: Bearer $TOKEN"
-```
-
----
-
-## Key Rotation
-
-Rotate the master encryption key without downtime:
-
-```bash
-phoenix rotate-master
-```
-
-```
-Master key rotated successfully
- Namespaces re-wrapped: 5
- Old key backed up to: /data/phoenix/master.key.prev
-```
-
-This generates a new master key, re-wraps all namespace DEKs, and persists everything atomically. If the process crashes mid-rotation, the two-phase commit protocol ensures automatic recovery:
-
-1. **Store save fails** — namespace entries roll back in memory, provider discards the pending key
-2. **Key file write fails** — store file restored from pre-rotation backup via atomic rename
-3. **Double failure** (store saved, key write failed, backup restore failed) — emergency key written to `.emergency-key` file for manual recovery
-
----
-
-## Master Key Protection
-
-The master key can be protected with a passphrase, similar to SSH key passphrases. This encrypts the key file at rest using Argon2id key derivation and AES-256-GCM. Existing unprotected deployments are completely unaffected — this feature is opt-in.
-
-### Initialize with a passphrase
-
-```bash
-phoenix-server --init /data/phoenix --passphrase "my-strong-passphrase"
-```
-
-The generated `master.key` file will be JSON (passphrase-protected) instead of raw base64.
-
-### Providing the passphrase at boot
-
-When the master key is protected, the server needs the passphrase to start. Three methods, in priority order:
-
-```bash
-# 1. Pipe from stdin (automation, agents, systemd)
-echo "my-passphrase" | phoenix-server --config /data/config.json --passphrase-stdin
-
-# 2. Environment variable (containers, systemd EnvironmentFile)
-PHOENIX_MASTER_PASSPHRASE="my-passphrase" phoenix-server --config /data/config.json
-
-# 3. Interactive TTY prompt (human at terminal)
-phoenix-server --config /data/config.json
-# → Enter master key passphrase: ****
-```
-
-### Add or change passphrase on existing deployment
-
-```bash
-phoenix-server --protect-key --config /data/config.json
-```
-
-This prompts for the current passphrase (if protected), then the new passphrase. Enter an empty new passphrase to remove protection.
-
-### Key rotation
-
-Key rotation automatically preserves passphrase protection. If the current key is passphrase-protected, the rotated key file will be too, using the same passphrase.
-
-> **Warning:** If you lose the passphrase, you lose your secrets. There is no recovery mechanism. Back up both the passphrase and the key file.
-
----
-
-## Emergency Access
-
-Break-glass offline secret retrieval when the server is down:
-
-```bash
-phoenix emergency get myapp/db-password --data-dir /data/phoenix
-```
-
-```
-*** EMERGENCY ACCESS ***
-Secret: myapp/db-password
-Data dir: /data/phoenix
-This bypasses the server and will be logged to the audit trail.
-Continue? [y/N] y
-
-*** Access logged to /data/phoenix/audit.log ***
-hunter2
-```
-
-For automation, use `--confirm` to skip the interactive prompt:
-
-```bash
-phoenix emergency get myapp/db-password --data-dir /data/phoenix --confirm
-```
-
-Emergency mode:
-- Reads `store.json` and `master.key` directly from disk — no server needed
-- Single secret only — no wildcards, no batch export, no listing
-- Requires explicit confirmation — interactive `[y/N]` prompt or `--confirm` flag
-- Prompts for passphrase if the master key is protected (via `--passphrase-stdin` or env/TTY)
-- Logs the access to `audit.log` with agent `emergency-local`
-
-This is a last resort for when the server is unavailable. It inherently requires filesystem access to the data directory, which limits it to the machine owner.
-
----
-
-## Audit Log
-
-Every operation is logged:
-
-```bash
-phoenix audit --last 10
-```
-
-```
-2026-02-26T10:00:01Z admin write myapp/db-password allowed 192.168.0.117
-2026-02-26T10:00:05Z deployer read myapp/db-password allowed 192.168.0.110
-2026-02-26T10:00:08Z scanner read myapp/db-password denied 10.0.0.5 acl
-2026-02-26T10:00:12Z deployer resolve myapp/api-key allowed 192.168.0.110
-2026-02-26T10:00:15Z rogue resolve production/key denied 10.0.0.99 attestation
-```
-
-Filter by agent or time range:
-
-```bash
-phoenix audit --agent deployer --since 2026-02-26T00:00:00Z
-```
-
-Secret values are never written to the audit log.
-
----
-
-## Import and Export
-
-### Import from .env files
-
-```bash
-phoenix import secrets.env --prefix myapp/
-```
-
-```
-imported: DB_PASSWORD -> myapp/db-password
-imported: API_KEY -> myapp/api-key
-imported 2 secrets
-```
-
-### Import from 1Password (one-time migration into Phoenix store)
-
-```bash
-export OP_SERVICE_ACCOUNT_TOKEN="ops_..."
-phoenix import --from 1password --vault Engineering --prefix myapp/
-```
-
-Options:
-- `--item ` import a single 1Password item
-- `--dry-run` preview mappings without writing
-- `--skip-existing` skip paths that already exist in Phoenix
-
-Token env override for import:
-- `PHOENIX_OP_TOKEN_ENV` — env var name containing the 1Password service account token (default: `OP_SERVICE_ACCOUNT_TOKEN`)
-
-### Export as .env format
-
-```bash
-phoenix export myapp/ --format env > .env
-```
-
-```
-DB_PASSWORD=hunter2
-API_KEY=sk-live-abc123
-```
-
----
-
-## Architecture
-
-```
-+-----------+ phoenix:// +---------+ AES-256-GCM +-------+
-| Agent / | ---- resolve ----> | Phoenix | ---- envelope ----> | Store |
-| Tool | (mTLS/ACL/ | Server | encryption | (JSON)|
-+-----------+ attestation) +---------+ +-------+
- |
- v
- +---------+
- | Audit |
- | Log |
- +---------+
-```
-
-**Encryption model:** Each namespace gets its own DEK (data encryption key) generated on first write. DEKs are wrapped with the master KEK (key encryption key) and stored alongside encrypted secrets. The KEK never touches disk in plaintext — it's stored in a separate key file with restricted permissions.
-
-**Authentication flow:** Request -> mTLS cert verification (if available) -> bearer token fallback -> ACL authorization -> attestation policy check -> secret access -> audit log.
-
-### Package Structure
-
-```
-cmd/
- phoenix/ CLI client (includes MCP server mode)
- phoenix-server/ API server
-
-internal/
- acl/ Access control lists with glob matching
- api/ REST API handlers and middleware
- audit/ Append-only structured audit logging
- ca/ Internal certificate authority (ECDSA P-256)
- config/ Server configuration loading and validation
- crypto/ AES-256-GCM encryption, key wrapping, key providers
- policy/ Attestation policy engine
- ref/ phoenix:// reference parsing and formatting
- store/ Encrypted secret storage with namespace isolation
-```
-
----
-
-## Configuration
-
-The server reads a JSON config file. `config.example.json` is a starter template;
-use the table below as the authoritative field reference.
-
-Key settings:
-
-| Field | Description | Default |
-|-------|-------------|---------|
-| `server.listen` | Bind address | `127.0.0.1:9090` |
-| `store.path` | Encrypted store file | `/data/store.json` |
-| `store.master_key` | Master key file | `/data/master.key` |
-| `store.backend` | Secret backend (`file` or `1password`) | `file` |
-| `acl.path` | ACL definition file | `/data/acl.json` |
-| `audit.path` | Audit log file | `/data/audit.log` |
-| `auth.bearer.enabled` | Allow bearer token auth | `true` |
-| `auth.mtls.enabled` | Enable mTLS | `false` |
-| `auth.mtls.require` | Reject connections without client cert | `false` |
-| `policy.path` | Attestation policy file (optional) | — |
-| `attestation.nonce.enabled` | Enable nonce challenge-response | `false` |
-| `attestation.nonce.max_age` | Nonce TTL (e.g. `"30s"`) | `30s` |
-| `attestation.token.enabled` | Enable short-lived token minting | `false` |
-| `attestation.token.ttl` | Token lifetime (e.g. `"15m"`) | `15m` |
-| `attestation.local_agent.enabled` | Enable local Unix-socket attestation agent | `false` |
-| `attestation.local_agent.socket_path` | Unix socket path for local attestation agent (required when enabled) | — |
-| `onepassword.vault` | 1Password vault name (required when `store.backend=1password`) | — |
-| `onepassword.service_account_token_env` | Token env var name for server runtime backend | `OP_SERVICE_ACCOUNT_TOKEN` |
-| `onepassword.cache_ttl` | Runtime read/list cache duration | `60s` |
-
-### 1Password Runtime Backend (Broker Mode, Read-Only)
-
-Phoenix can broker access to secrets stored in 1Password:
-
-```json
-{
- "store": {
- "backend": "1password"
- },
- "onepassword": {
- "vault": "Engineering",
- "service_account_token_env": "OP_SERVICE_ACCOUNT_TOKEN",
- "cache_ttl": "60s"
- }
-}
-```
-
-Behavior:
-- `GET/resolve/list` go through Phoenix ACL + attestation + audit, then read from 1Password
-- `set/delete` are blocked (`read-only backend`)
-- path mapping: `phoenix://myapp/api-key` -> `op://Engineering/myapp/api-key`
-
-Rollback to managed mode:
-1. set `store.backend` back to `"file"`
-2. restart `phoenix-server`
-
-Troubleshooting:
-- missing `op` binary or token: server fails fast at startup with a clear error
-- runtime list/read failures: request fails, audit still records access attempt
-- `onepassword.cache_ttl` must be a duration string (for example `"60s"`). A
- bare number like `60` is invalid.
-
-### Environment Variables (CLI)
-
-| Variable | Description |
-|----------|-------------|
-| `PHOENIX_SERVER` | Server URL (default: `http://127.0.0.1:9090`) |
-| `PHOENIX_TOKEN` | Bearer token for authentication |
-| `PHOENIX_CA_CERT` | CA certificate for TLS verification |
-| `PHOENIX_CLIENT_CERT` | Client certificate for mTLS |
-| `PHOENIX_CLIENT_KEY` | Client key for mTLS |
-| `PHOENIX_POLICY` | Policy file path (for `phoenix policy` commands) |
-| `PHOENIX_OP_TOKEN_ENV` | (Import only) env var name holding 1Password token |
-
----
-
-## Docker
-
-```bash
-docker pull phoenixsec/phoenix:latest
-
-# First run: initialize the data directory
-docker run --rm -v phoenix-data:/data phoenixsec/phoenix:latest --init /data
-
-# Note the admin token printed to stdout — save it!
-
-# Start the server
-docker run -d --name phoenix \
- -v phoenix-data:/data \
- -p 9090:9090 \
- --restart unless-stopped \
- phoenixsec/phoenix:latest
-```
-
-Tag strategy:
-- `latest` (most recent release)
-- `vX.Y.Z` (exact release tag)
-- `vX.Y` (minor line)
-
-Or with Docker Compose:
-
-```yaml
-services:
- phoenix:
- image: phoenixsec/phoenix:latest
- ports:
- - "9090:9090"
- volumes:
- - phoenix-data:/data
- restart: unless-stopped
-
-volumes:
- phoenix-data:
-```
-
-```bash
-# First-time setup
-docker compose run --rm phoenix --init /data
-
-# Then start normally
-docker compose up -d
-```
-
-> **Docker note:** The generated config inside the container defaults to `127.0.0.1:9090`.
-> For Docker port mapping (`-p 9090:9090`) to work, set `server.listen` to `0.0.0.0:9090`
-> in the container's config file, or use the provided `config.example.json` which already does this.
+| Topic | Guide |
+|-------|-------|
+| First install and setup | [Getting Started](docs/getting-started.md) |
+| CLI commands and workflows | [CLI Usage](docs/cli-usage.md) |
+| Auth, mTLS, and identity | [Authentication](docs/authentication.md) |
+| Per-path policy and attestation | [Policy and Attestation](docs/policy-and-attestation.md) |
+| Sealed responses and multi-agent | [Sealed Responses](docs/sealed-responses.md) / [Multi-Agent Setup](docs/multi-agent-setup.md) |
+| MCP, SDKs, OpenClaw, API | [Integrations](docs/integrations.md) |
+| Key rotation and emergency access | [Key Management](docs/key-management.md) |
+| Server config and Docker | [Configuration](docs/configuration.md) |
+| LAN and multi-host deployment | [LAN Deployment](docs/lan-deployment.md) |
+| Migrating from `.env` files | [Migration Guide](docs/migration-env-to-phoenix.md) |
+| Threat model and security boundaries | [Threat Model](docs/threat-model.md) |
+| API endpoint reference | [API Reference](docs/api-reference-index.md) |
+| Admin token lifecycle | [Admin Token Lifecycle](docs/admin-token-lifecycle.md) |
+| Roadmap | [Roadmap](docs/roadmap.md) |
---
## Security Model
-**What Phoenix protects against:**
-- Secret exfiltration risk reduction via reference-first workflows (`phoenix://` + `phoenix exec`)
-- Unauthorized access (ACL + configurable attestation policy per path)
-- Credential replay from wrong network location (source IP binding)
-- Stolen bearer tokens accessing sensitive paths (deny_bearer + mTLS)
-- Data at rest exposure (AES-256-GCM envelope encryption)
-- Audit trail gaps (append-only log, values never logged, every access recorded)
-- Crash corruption during key rotation (two-phase commit + emergency recovery)
-- Lateral movement between agents (per-agent ACL with namespace isolation)
+**Phoenix helps with:**
+- keeping secret values out of model context, prompts, and tool responses
+- per-agent authorization and scoped access control
+- cryptographic identity via mTLS and sealed-response key pairs
+- audit visibility for every read, deny, and resolution attempt
+- safer multi-agent operation on shared hosts with separated identities
-**What Phoenix does NOT protect against:**
-- Root/kernel compromise on the Phoenix host
-- Side-channel or hardware attacks
-- Malicious admin with direct file system access to the key file
+**Phoenix does not solve:**
+- root/kernel compromise on the host
+- malicious admins with direct file access
+- every possible plaintext path without policy and deployment discipline
-Phoenix provides real security infrastructure — encryption, identity, access control, attestation, audit — in a package simple enough for an AI agent to deploy and manage. It is not a replacement for HSMs or cloud KMS in high-security enterprise environments.
+See the full [Threat Model](docs/threat-model.md).
---
## Development
```bash
-# Run all tests
-go test ./... -count=1
-
-# Run tests with verbose output
-go test ./... -count=1 -v
-
-# Build both binaries
-go build -o bin/ ./cmd/...
+go test ./... -count=1 # run all tests
+go build -o bin/ ./cmd/... # build both binaries
```
-External dependencies: `golang.org/x/crypto` (Argon2id for passphrase KDF) and `golang.org/x/term` (TTY passphrase input).
+Dependencies: `golang.org/x/crypto`, `golang.org/x/term`.
---
diff --git a/cmd/phoenix/main.go b/cmd/phoenix/main.go
index 73f2082..caab426 100644
--- a/cmd/phoenix/main.go
+++ b/cmd/phoenix/main.go
@@ -256,6 +256,20 @@ func main() {
}
case "init":
err = cmdInit(args)
+ case "keypair":
+ if len(args) < 1 {
+ fmt.Fprintln(os.Stderr, "usage: phoenix keypair ")
+ os.Exit(1)
+ }
+ switch args[0] {
+ case "generate":
+ err = cmdKeypairGenerate(args[1:])
+ case "show":
+ err = cmdKeypairShow(args[1:])
+ default:
+ fmt.Fprintf(os.Stderr, "unknown keypair subcommand: %s\n", args[0])
+ os.Exit(1)
+ }
case "version", "--version", "-V":
fmt.Printf("phoenix %s\n", version.Version)
case "help", "--help", "-h":
@@ -303,6 +317,8 @@ Usage:
phoenix agent-sock attest [--socket ] Attest via local Unix socket agent
phoenix agent-sock token --agent Mint/cache short-lived token via socket
phoenix agent-sock resolve Resolve refs using cached socket token
+ phoenix keypair generate [-o dir] Generate X25519 seal key pair
+ phoenix keypair show Show public key for a seal key pair
phoenix mcp-server Run MCP server (stdio JSON-RPC)
phoenix mcp-server --http :8080 Run MCP server (Streamable HTTP)
phoenix init Initialize data directory
@@ -313,6 +329,7 @@ Environment:
PHOENIX_CA_CERT CA certificate for TLS verification
PHOENIX_CLIENT_CERT Client certificate for mTLS authentication
PHOENIX_CLIENT_KEY Client key for mTLS authentication
+ PHOENIX_SEAL_KEY Seal private key file (enables sealed mode)
PHOENIX_POLICY Path to attestation policy file (JSON)
PHOENIX_TOOL Tool/skill name for attestation (X-Phoenix-Tool header)
PHOENIX_MCP_TOKEN Bearer token for MCP HTTP client auth (--http mode)`)
@@ -365,7 +382,12 @@ func cmdGet(args []string) error {
return fmt.Errorf("usage: phoenix get ")
}
- resp, err := apiRequest("GET", "/v1/secrets/"+args[0], nil)
+ sealPrivKey, err := loadSealKey()
+ if err != nil {
+ return fmt.Errorf("loading seal key: %w", err)
+ }
+
+ resp, err := apiRequestWithHeaders("GET", "/v1/secrets/"+args[0], nil, sealHeaders(sealPrivKey))
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
@@ -375,6 +397,24 @@ func cmdGet(args []string) error {
return handleError(resp)
}
+ if sealPrivKey != nil {
+ var sealed struct {
+ SealedValue interface{} `json:"sealed_value"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&sealed); err != nil {
+ return fmt.Errorf("decoding response: %w", err)
+ }
+ if sealed.SealedValue == nil {
+ return fmt.Errorf("expected sealed_value in response but got none")
+ }
+ value, err := decryptSealedValue(sealed.SealedValue, sealPrivKey)
+ if err != nil {
+ return err
+ }
+ fmt.Print(value)
+ return nil
+ }
+
var secret struct {
Value string `json:"value"`
}
@@ -918,6 +958,11 @@ func cmdResolve(args []string) error {
return fmt.Errorf("usage: phoenix resolve [--signed] [ref...]")
}
+ sealPrivKey, err := loadSealKey()
+ if err != nil {
+ return fmt.Errorf("loading seal key: %w", err)
+ }
+
var bodyMap map[string]interface{}
if signed {
@@ -975,7 +1020,7 @@ func cmdResolve(args []string) error {
}
body, _ := json.Marshal(bodyMap)
- resp, err := apiRequest("POST", "/v1/resolve", strings.NewReader(string(body)))
+ resp, err := apiRequestWithHeaders("POST", "/v1/resolve", strings.NewReader(string(body)), sealHeaders(sealPrivKey))
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
@@ -985,32 +1030,69 @@ func cmdResolve(args []string) error {
return handleError(resp)
}
- var result struct {
- Values map[string]string `json:"values"`
- Errors map[string]string `json:"errors"`
- }
- if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
- return fmt.Errorf("decoding response: %w", err)
+ // Decode response — handle sealed or plaintext
+ values := make(map[string]string)
+ errs := make(map[string]string)
+
+ if sealPrivKey != nil {
+ var result struct {
+ SealedValues map[string]interface{} `json:"sealed_values"`
+ Errors map[string]string `json:"errors"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return fmt.Errorf("decoding response: %w", err)
+ }
+ errs = result.Errors
+ for ref, raw := range result.SealedValues {
+ if envMap, ok := raw.(map[string]interface{}); ok {
+ if envRef, _ := envMap["ref"].(string); envRef != ref {
+ return fmt.Errorf("sealed envelope ref mismatch: map key %q, envelope %q", ref, envRef)
+ }
+ }
+ val, err := decryptSealedValue(raw, sealPrivKey)
+ if err != nil {
+ return fmt.Errorf("decrypting %s: %w", ref, err)
+ }
+ values[ref] = val
+ }
+ } else {
+ var result struct {
+ Values map[string]string `json:"values"`
+ Errors map[string]string `json:"errors"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return fmt.Errorf("decoding response: %w", err)
+ }
+ values = result.Values
+ errs = result.Errors
}
// Single ref: output raw value for piping
if len(refs) == 1 {
- if errMsg, ok := result.Errors[refs[0]]; ok {
+ if errMsg, ok := errs[refs[0]]; ok {
return fmt.Errorf("%s: %s", refs[0], errMsg)
}
- fmt.Print(result.Values[refs[0]])
+ if _, ok := values[refs[0]]; !ok {
+ return fmt.Errorf("%s: no value returned by server", refs[0])
+ }
+ fmt.Print(values[refs[0]])
return nil
}
// Multiple refs: output ref → value pairs
var hasErr bool
for _, ref := range refs {
- if errMsg, ok := result.Errors[ref]; ok {
+ if errMsg, ok := errs[ref]; ok {
fmt.Fprintf(os.Stderr, "%s: error: %s\n", ref, errMsg)
hasErr = true
continue
}
- fmt.Printf("%s\t%s\n", ref, result.Values[ref])
+ if _, ok := values[ref]; !ok {
+ fmt.Fprintf(os.Stderr, "%s: error: no value returned by server\n", ref)
+ hasErr = true
+ continue
+ }
+ fmt.Printf("%s\t%s\n", ref, values[ref])
}
if hasErr {
return fmt.Errorf("some references failed to resolve")
@@ -1069,6 +1151,11 @@ func cmdExec(args []string) error {
return err
}
+ sealPrivKey, sealErr := loadSealKey()
+ if sealErr != nil {
+ return fmt.Errorf("loading seal key: %w", sealErr)
+ }
+
// Parse --env flags and --output-env before "--", command after "--"
var envMappings []string
var cmdArgs []string
@@ -1146,6 +1233,7 @@ func cmdExec(args []string) error {
}
body, _ := json.Marshal(map[string]interface{}{"refs": refs})
+ hdrs := sealHeaders(sealPrivKey)
var resp *http.Response
var err error
if timeout > 0 {
@@ -1160,9 +1248,12 @@ func cmdExec(args []string) error {
req.Header.Set("Authorization", "Bearer "+token)
}
req.Header.Set("Content-Type", "application/json")
+ for k, v := range hdrs {
+ req.Header.Set(k, v)
+ }
resp, err = httpClient.Do(req)
} else {
- resp, err = apiRequest("POST", "/v1/resolve", strings.NewReader(string(body)))
+ resp, err = apiRequestWithHeaders("POST", "/v1/resolve", strings.NewReader(string(body)), hdrs)
}
if err != nil {
return fmt.Errorf("resolve request failed: %w", err)
@@ -1173,27 +1264,56 @@ func cmdExec(args []string) error {
return handleError(resp)
}
- var result struct {
- Values map[string]string `json:"values"`
- Errors map[string]string `json:"errors"`
- }
- if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
- return fmt.Errorf("decoding resolve response: %w", err)
+ // Decode response — handle sealed or plaintext
+ resolvedValues := make(map[string]string)
+ var resolveErrors map[string]string
+
+ if sealPrivKey != nil {
+ var result struct {
+ SealedValues map[string]interface{} `json:"sealed_values"`
+ Errors map[string]string `json:"errors"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return fmt.Errorf("decoding resolve response: %w", err)
+ }
+ resolveErrors = result.Errors
+ for ref, raw := range result.SealedValues {
+ if envMap, ok := raw.(map[string]interface{}); ok {
+ if envRef, _ := envMap["ref"].(string); envRef != ref {
+ return fmt.Errorf("sealed envelope ref mismatch: map key %q, envelope %q", ref, envRef)
+ }
+ }
+ val, err := decryptSealedValue(raw, sealPrivKey)
+ if err != nil {
+ return fmt.Errorf("decrypting %s: %w", ref, err)
+ }
+ resolvedValues[ref] = val
+ }
+ } else {
+ var result struct {
+ Values map[string]string `json:"values"`
+ Errors map[string]string `json:"errors"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return fmt.Errorf("decoding resolve response: %w", err)
+ }
+ resolvedValues = result.Values
+ resolveErrors = result.Errors
}
// Check for resolution errors
- if len(result.Errors) > 0 {
- for ref, errMsg := range result.Errors {
+ if len(resolveErrors) > 0 {
+ for ref, errMsg := range resolveErrors {
fmt.Fprintf(os.Stderr, "error resolving %s: %s\n", ref, errMsg)
}
- return fmt.Errorf("failed to resolve %d reference(s)", len(result.Errors))
+ return fmt.Errorf("failed to resolve %d reference(s)", len(resolveErrors))
}
// If --output-env is set, write resolved env to file and exit
if outputEnvPath != "" {
var lines []string
for _, m := range mappings {
- val, ok := result.Values[m.ref]
+ val, ok := resolvedValues[m.ref]
if !ok {
return fmt.Errorf("no value returned for %s", m.ref)
}
@@ -1214,7 +1334,8 @@ func cmdExec(args []string) error {
key := e[:strings.IndexByte(e, '=')]
switch key {
case "PHOENIX_TOKEN", "PHOENIX_CLIENT_CERT", "PHOENIX_CLIENT_KEY",
- "PHOENIX_CA_CERT", "PHOENIX_SERVER", "PHOENIX_POLICY":
+ "PHOENIX_CA_CERT", "PHOENIX_SERVER", "PHOENIX_POLICY",
+ "PHOENIX_SEAL_KEY":
continue // strip broker credentials
}
if maskEnv {
@@ -1226,7 +1347,7 @@ func cmdExec(args []string) error {
env = append(env, e)
}
for _, m := range mappings {
- val, ok := result.Values[m.ref]
+ val, ok := resolvedValues[m.ref]
if !ok {
return fmt.Errorf("no value returned for %s", m.ref)
}
@@ -1339,6 +1460,170 @@ func cmdCertIssue(args []string) error {
return nil
}
+// --- Seal key helpers ---
+
+// loadSealKey loads the seal private key from PHOENIX_SEAL_KEY env var.
+// Returns nil, nil if the env var is not set (unsealed mode).
+func loadSealKey() (*[32]byte, error) {
+ keyPath := os.Getenv("PHOENIX_SEAL_KEY")
+ if keyPath == "" {
+ return nil, nil
+ }
+ return crypto.LoadSealPrivateKey(keyPath)
+}
+
+// sealHeaders returns HTTP headers for sealed requests.
+// Returns nil if no seal key is loaded.
+func sealHeaders(privKey *[32]byte) map[string]string {
+ if privKey == nil {
+ return nil
+ }
+ pubKey := crypto.DeriveSealPublicKey(privKey)
+ return map[string]string{
+ "X-Phoenix-Seal-Key": crypto.EncodeSealKey(pubKey),
+ }
+}
+
+// decryptSealedValue decrypts a sealed envelope from a raw JSON object.
+func decryptSealedValue(raw interface{}, privKey *[32]byte) (string, error) {
+ envJSON, err := json.Marshal(raw)
+ if err != nil {
+ return "", fmt.Errorf("marshaling sealed envelope: %w", err)
+ }
+ var env crypto.SealedEnvelope
+ if err := json.Unmarshal(envJSON, &env); err != nil {
+ return "", fmt.Errorf("parsing sealed envelope: %w", err)
+ }
+ payload, err := crypto.OpenSealedEnvelope(&env, privKey)
+ if err != nil {
+ return "", fmt.Errorf("decrypting sealed value: %w", err)
+ }
+ return payload.Value, nil
+}
+
+// --- Keypair commands ---
+
+func cmdKeypairGenerate(args []string) error {
+ if err := requireAuth(); err != nil {
+ return err
+ }
+
+ var name, output string
+ force := false
+ i := 0
+ if i < len(args) && !strings.HasPrefix(args[i], "-") {
+ name = args[i]
+ i++
+ }
+ for i < len(args) {
+ switch args[i] {
+ case "-o", "--output":
+ i++
+ if i < len(args) {
+ output = args[i]
+ }
+ case "--force":
+ force = true
+ }
+ i++
+ }
+
+ if name == "" {
+ return fmt.Errorf("usage: phoenix keypair generate [--output ] [--force]")
+ }
+
+ urlPath := "/v1/keypair"
+ if force {
+ urlPath += "?force=true"
+ }
+
+ body, _ := json.Marshal(map[string]string{"agent_name": name})
+ resp, err := apiRequest("POST", urlPath, strings.NewReader(string(body)))
+ if err != nil {
+ return fmt.Errorf("request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ return handleError(resp)
+ }
+
+ var result struct {
+ AgentName string `json:"agent_name"`
+ SealPublicKey string `json:"seal_public_key"`
+ SealPrivateKey string `json:"seal_private_key"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return fmt.Errorf("decoding response: %w", err)
+ }
+
+ // Determine output path
+ if output == "" {
+ home, _ := os.UserHomeDir()
+ dir := filepath.Join(home, ".config", "phoenix", "keys")
+ if err := os.MkdirAll(dir, 0700); err != nil {
+ return fmt.Errorf("creating key directory: %w", err)
+ }
+ output = filepath.Join(dir, name+".seal.key")
+ }
+
+ // Ensure parent directory has correct permissions
+ dir := filepath.Dir(output)
+ if err := os.MkdirAll(dir, 0700); err != nil {
+ return fmt.Errorf("creating key directory: %w", err)
+ }
+
+ if err := os.WriteFile(output, []byte(result.SealPrivateKey), 0600); err != nil {
+ return fmt.Errorf("writing private key: %w", err)
+ }
+ // Harden permissions even if the file already existed with weaker mode
+ if err := os.Chmod(output, 0600); err != nil {
+ return fmt.Errorf("setting key file permissions: %w", err)
+ }
+
+ fmt.Printf("Seal keypair generated for agent %q\n", name)
+ fmt.Printf(" private key: %s\n", output)
+ fmt.Printf(" public key: %s\n", result.SealPublicKey)
+ fmt.Println()
+ fmt.Printf("Set PHOENIX_SEAL_KEY=%s to enable sealed mode\n", output)
+ return nil
+}
+
+func cmdKeypairShow(args []string) error {
+ if err := requireAuth(); err != nil {
+ return err
+ }
+
+ if len(args) < 1 {
+ return fmt.Errorf("usage: phoenix keypair show ")
+ }
+ name := args[0]
+
+ resp, err := apiRequest("GET", "/v1/agents/"+name+"/seal-key", nil)
+ if err != nil {
+ return fmt.Errorf("request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ return handleError(resp)
+ }
+
+ var result struct {
+ AgentName string `json:"agent_name"`
+ SealPublicKey string `json:"seal_public_key"`
+ }
+ json.NewDecoder(resp.Body).Decode(&result)
+
+ fmt.Printf("Agent: %s\n", result.AgentName)
+ if result.SealPublicKey == "" {
+ fmt.Println("Seal key: (none)")
+ } else {
+ fmt.Printf("Seal key: %s\n", result.SealPublicKey)
+ }
+ return nil
+}
+
func cmdPolicyShow(args []string) error {
if len(args) < 1 {
return fmt.Errorf("usage: phoenix policy show ")
@@ -1969,15 +2254,24 @@ func cmdAgentSockResolve(args []string) error {
saveTokenCache(cache)
}
+ // Load seal key for sealed mode
+ sealPrivKey, sealErr := loadSealKey()
+ if sealErr != nil {
+ return fmt.Errorf("loading seal key: %w", sealErr)
+ }
+
// Resolve using the short-lived token
body, _ := json.Marshal(map[string]interface{}{"refs": refs})
- url := serverURL + "/v1/resolve"
- req, err := http.NewRequest("POST", url, strings.NewReader(string(body)))
+ reqURL := serverURL + "/v1/resolve"
+ req, err := http.NewRequest("POST", reqURL, strings.NewReader(string(body)))
if err != nil {
return fmt.Errorf("building request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("Content-Type", "application/json")
+ for k, v := range sealHeaders(sealPrivKey) {
+ req.Header.Set(k, v)
+ }
resp, err := httpClient.Do(req)
if err != nil {
@@ -1989,32 +2283,61 @@ func cmdAgentSockResolve(args []string) error {
return handleError(resp)
}
- var result struct {
- Values map[string]string `json:"values"`
- Errors map[string]string `json:"errors"`
- }
- if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
- return fmt.Errorf("decoding response: %w", err)
+ // Decode response — handle sealed or plaintext
+ values := make(map[string]string)
+ errs := make(map[string]string)
+
+ if sealPrivKey != nil {
+ var result struct {
+ SealedValues map[string]interface{} `json:"sealed_values"`
+ Errors map[string]string `json:"errors"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return fmt.Errorf("decoding response: %w", err)
+ }
+ errs = result.Errors
+ for ref, raw := range result.SealedValues {
+ if envMap, ok := raw.(map[string]interface{}); ok {
+ if envRef, _ := envMap["ref"].(string); envRef != ref {
+ return fmt.Errorf("sealed envelope ref mismatch: map key %q, envelope %q", ref, envRef)
+ }
+ }
+ val, err := decryptSealedValue(raw, sealPrivKey)
+ if err != nil {
+ return fmt.Errorf("decrypting %s: %w", ref, err)
+ }
+ values[ref] = val
+ }
+ } else {
+ var result struct {
+ Values map[string]string `json:"values"`
+ Errors map[string]string `json:"errors"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return fmt.Errorf("decoding response: %w", err)
+ }
+ values = result.Values
+ errs = result.Errors
}
// Single ref: output raw value
if len(refs) == 1 {
- if errMsg, ok := result.Errors[refs[0]]; ok {
+ if errMsg, ok := errs[refs[0]]; ok {
return fmt.Errorf("%s: %s", refs[0], errMsg)
}
- fmt.Print(result.Values[refs[0]])
+ fmt.Print(values[refs[0]])
return nil
}
// Multiple refs
var hasErr bool
for _, ref := range refs {
- if errMsg, ok := result.Errors[ref]; ok {
+ if errMsg, ok := errs[ref]; ok {
fmt.Fprintf(os.Stderr, "%s: error: %s\n", ref, errMsg)
hasErr = true
continue
}
- fmt.Printf("%s\t%s\n", ref, result.Values[ref])
+ fmt.Printf("%s\t%s\n", ref, values[ref])
}
if hasErr {
return fmt.Errorf("some references failed to resolve")
diff --git a/cmd/phoenix/mcp.go b/cmd/phoenix/mcp.go
index 4412756..bd3e102 100644
--- a/cmd/phoenix/mcp.go
+++ b/cmd/phoenix/mcp.go
@@ -25,6 +25,7 @@
package main
import (
+ "encoding/base64"
"encoding/json"
"fmt"
"io"
@@ -32,9 +33,14 @@ import (
"os"
"strings"
+ "github.com/phoenixsec/phoenix/internal/crypto"
"github.com/phoenixsec/phoenix/internal/version"
)
+// mcpSealPrivKey holds the loaded seal private key for MCP sealed mode.
+// Set during cmdMCP startup if PHOENIX_SEAL_KEY is configured.
+var mcpSealPrivKey *[32]byte
+
// JSON-RPC wire types.
type mcpRequest struct {
@@ -96,7 +102,7 @@ type mcpCallToolParams struct {
// Tool definitions with JSON Schema input schemas.
-var mcpTools = []mcpTool{
+var mcpBaseTools = []mcpTool{
{
Name: "phoenix_resolve",
Description: "Resolve one or more phoenix:// secret references to their values. References are opaque URIs like phoenix://namespace/secret-name. Returns the resolved values for each reference.",
@@ -141,6 +147,32 @@ var mcpTools = []mcpTool{
},
}
+var mcpUnsealTool = mcpTool{
+ Name: "phoenix_unseal",
+ Description: "Decrypt a sealed secret value. The decrypted value will be visible in this conversation. Only works when allow_unseal policy is set for the secret's path.",
+ InputSchema: json.RawMessage(`{
+ "type": "object",
+ "properties": {
+ "sealed": {
+ "type": "string",
+ "description": "Sealed token (PHOENIX_SEALED:...)"
+ }
+ },
+ "required": ["sealed"]
+ }`),
+}
+
+// mcpGetTools returns the tool list, including phoenix_unseal only when sealed mode is active.
+func mcpGetTools() []mcpTool {
+ if mcpSealPrivKey != nil {
+ tools := make([]mcpTool, len(mcpBaseTools)+1)
+ copy(tools, mcpBaseTools)
+ tools[len(mcpBaseTools)] = mcpUnsealTool
+ return tools
+ }
+ return mcpBaseTools
+}
+
// mcpHandleRequest processes a single JSON-RPC request and writes the
// response to enc. Notifications (no id) are silently ignored.
func mcpHandleRequest(req mcpRequest, enc *json.Encoder, logger *log.Logger) {
@@ -162,7 +194,7 @@ func mcpHandleRequest(req mcpRequest, enc *json.Encoder, logger *log.Logger) {
})
case "tools/list":
- mcpSendResult(enc, req.ID, mcpListToolsResult{Tools: mcpTools})
+ mcpSendResult(enc, req.ID, mcpListToolsResult{Tools: mcpGetTools()})
case "tools/call":
var params mcpCallToolParams
@@ -187,8 +219,18 @@ func cmdMCP(args []string) error {
return err
}
+ // Load seal key if configured.
+ sealKey, err := loadSealKey()
+ if err != nil {
+ return fmt.Errorf("loading seal key: %w", err)
+ }
+ mcpSealPrivKey = sealKey
+
// All logging goes to stderr — stdout is the MCP protocol channel.
logger := log.New(os.Stderr, "phoenix-mcp: ", 0)
+ if mcpSealPrivKey != nil {
+ logger.Println("sealed mode enabled")
+ }
logger.Println("server starting (stdio)")
dec := json.NewDecoder(os.Stdin)
@@ -221,6 +263,8 @@ func mcpDispatchTool(name string, args json.RawMessage, logger *log.Logger) (str
return mcpToolGet(args, logger)
case "phoenix_list":
return mcpToolList(args, logger)
+ case "phoenix_unseal":
+ return mcpToolUnseal(args, logger)
default:
return fmt.Sprintf("Unknown tool: %s", name), true
}
@@ -243,8 +287,14 @@ func mcpToolResolve(args json.RawMessage, logger *log.Logger) (string, bool) {
return fmt.Sprintf("Internal error: %v", err), true
}
- resp, err := apiRequestWithHeaders("POST", "/v1/resolve", strings.NewReader(string(body)),
- map[string]string{"X-Phoenix-Tool": "phoenix_resolve"})
+ hdrs := map[string]string{"X-Phoenix-Tool": "phoenix_resolve"}
+ if mcpSealPrivKey != nil {
+ for k, v := range sealHeaders(mcpSealPrivKey) {
+ hdrs[k] = v
+ }
+ }
+
+ resp, err := apiRequestWithHeaders("POST", "/v1/resolve", strings.NewReader(string(body)), hdrs)
if err != nil {
return fmt.Sprintf("Request failed: %v", err), true
}
@@ -254,6 +304,51 @@ func mcpToolResolve(args json.RawMessage, logger *log.Logger) (string, bool) {
return fmt.Sprintf("Server error: HTTP %d", resp.StatusCode), true
}
+ // Sealed mode: return opaque PHOENIX_SEALED: tokens
+ if mcpSealPrivKey != nil {
+ var result struct {
+ SealedValues map[string]interface{} `json:"sealed_values"`
+ Errors map[string]string `json:"errors"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return fmt.Sprintf("Failed to decode response: %v", err), true
+ }
+
+ if len(result.SealedValues) == 0 && len(result.Errors) > 0 {
+ var msgs []string
+ for ref, errMsg := range result.Errors {
+ msgs = append(msgs, fmt.Sprintf("%s: %s", ref, errMsg))
+ }
+ return strings.Join(msgs, "\n"), true
+ }
+
+ var lines []string
+ hasErrors := len(result.Errors) > 0
+ for _, ref := range params.Refs {
+ if raw, ok := result.SealedValues[ref]; ok {
+ if envMap, ok := raw.(map[string]interface{}); ok {
+ if envRef, _ := envMap["ref"].(string); envRef != ref {
+ lines = append(lines, fmt.Sprintf("%s: ERROR: sealed envelope ref mismatch (envelope has %q)", ref, envRef))
+ hasErrors = true
+ continue
+ }
+ }
+ envJSON, _ := json.Marshal(raw)
+ sealToken := "PHOENIX_SEALED:" + base64.StdEncoding.EncodeToString(envJSON)
+ lines = append(lines, fmt.Sprintf("%s = %s", ref, sealToken))
+ } else if errMsg, ok := result.Errors[ref]; ok {
+ lines = append(lines, fmt.Sprintf("%s: ERROR: %s", ref, errMsg))
+ } else {
+ lines = append(lines, fmt.Sprintf("%s: ERROR: no value returned by server", ref))
+ hasErrors = true
+ }
+ }
+
+ logger.Printf("resolved %d/%d refs (sealed)", len(result.SealedValues), len(params.Refs))
+ return strings.Join(lines, "\n"), hasErrors
+ }
+
+ // Plaintext mode
var result struct {
Values map[string]string `json:"values"`
Errors map[string]string `json:"errors"`
@@ -262,7 +357,6 @@ func mcpToolResolve(args json.RawMessage, logger *log.Logger) (string, bool) {
return fmt.Sprintf("Failed to decode response: %v", err), true
}
- // If all refs failed, report errors.
if len(result.Values) == 0 && len(result.Errors) > 0 {
var msgs []string
for ref, errMsg := range result.Errors {
@@ -271,7 +365,6 @@ func mcpToolResolve(args json.RawMessage, logger *log.Logger) (string, bool) {
return strings.Join(msgs, "\n"), true
}
- // Build output: values first, then any partial errors.
var lines []string
for _, ref := range params.Refs {
if val, ok := result.Values[ref]; ok {
@@ -282,7 +375,6 @@ func mcpToolResolve(args json.RawMessage, logger *log.Logger) (string, bool) {
}
logger.Printf("resolved %d/%d refs", len(result.Values), len(params.Refs))
-
hasErrors := len(result.Errors) > 0
return strings.Join(lines, "\n"), hasErrors
}
@@ -299,8 +391,14 @@ func mcpToolGet(args json.RawMessage, logger *log.Logger) (string, bool) {
return "Path is required", true
}
- resp, err := apiRequestWithHeaders("GET", "/v1/secrets/"+params.Path, nil,
- map[string]string{"X-Phoenix-Tool": "phoenix_get"})
+ hdrs := map[string]string{"X-Phoenix-Tool": "phoenix_get"}
+ if mcpSealPrivKey != nil {
+ for k, v := range sealHeaders(mcpSealPrivKey) {
+ hdrs[k] = v
+ }
+ }
+
+ resp, err := apiRequestWithHeaders("GET", "/v1/secrets/"+params.Path, nil, hdrs)
if err != nil {
return fmt.Sprintf("Request failed: %v", err), true
}
@@ -317,6 +415,28 @@ func mcpToolGet(args json.RawMessage, logger *log.Logger) (string, bool) {
return fmt.Sprintf("%s: HTTP %d", params.Path, resp.StatusCode), true
}
+ // Sealed mode: return opaque PHOENIX_SEALED: token
+ if mcpSealPrivKey != nil {
+ var sealed struct {
+ SealedValue interface{} `json:"sealed_value"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&sealed); err != nil {
+ return fmt.Sprintf("Failed to decode response: %v", err), true
+ }
+ if sealed.SealedValue == nil {
+ return fmt.Sprintf("%s: expected sealed_value in response", params.Path), true
+ }
+ if envMap, ok := sealed.SealedValue.(map[string]interface{}); ok {
+ if envPath, _ := envMap["path"].(string); envPath != params.Path {
+ return fmt.Sprintf("%s: sealed envelope path mismatch (envelope has %q)", params.Path, envPath), true
+ }
+ }
+ envJSON, _ := json.Marshal(sealed.SealedValue)
+ token := "PHOENIX_SEALED:" + base64.StdEncoding.EncodeToString(envJSON)
+ logger.Printf("get %s (sealed)", params.Path)
+ return token, false
+ }
+
var result struct {
Path string `json:"path"`
Value string `json:"value"`
@@ -380,6 +500,73 @@ func mcpToolList(args json.RawMessage, logger *log.Logger) (string, bool) {
return strings.Join(result.Paths, "\n"), false
}
+// mcpToolUnseal decrypts a sealed secret token locally.
+func mcpToolUnseal(args json.RawMessage, logger *log.Logger) (string, bool) {
+ if mcpSealPrivKey == nil {
+ return "Sealed mode is not enabled (PHOENIX_SEAL_KEY not set)", true
+ }
+
+ var params struct {
+ Sealed string `json:"sealed"`
+ }
+ if err := json.Unmarshal(args, ¶ms); err != nil {
+ return fmt.Sprintf("Invalid arguments: %v", err), true
+ }
+
+ const prefix = "PHOENIX_SEALED:"
+ if !strings.HasPrefix(params.Sealed, prefix) {
+ return "Invalid sealed token: must start with PHOENIX_SEALED:", true
+ }
+
+ envJSON, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(params.Sealed, prefix))
+ if err != nil {
+ return fmt.Sprintf("Invalid sealed token: bad base64: %v", err), true
+ }
+
+ var env crypto.SealedEnvelope
+ if err := json.Unmarshal(envJSON, &env); err != nil {
+ return fmt.Sprintf("Invalid sealed envelope: %v", err), true
+ }
+
+ // Check allow_unseal via server policy (authoritative source)
+ allowed, err := mcpCheckAllowUnseal(env.Path)
+ if err != nil {
+ return fmt.Sprintf("Policy check failed: %v", err), true
+ }
+ if !allowed {
+ return "Unseal denied: allow_unseal is not set for this path", true
+ }
+
+ payload, err := crypto.OpenSealedEnvelope(&env, mcpSealPrivKey)
+ if err != nil {
+ return fmt.Sprintf("Decryption failed: %v", err), true
+ }
+
+ logger.Printf("unseal %s (value now visible in conversation)", env.Path)
+ return payload.Value, false
+}
+
+// mcpCheckAllowUnseal queries the server's authoritative policy for allow_unseal.
+func mcpCheckAllowUnseal(path string) (bool, error) {
+ resp, err := apiRequest("GET", "/v1/policy/check?path="+path+"&check=allow_unseal", nil)
+ if err != nil {
+ return false, fmt.Errorf("request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ return false, fmt.Errorf("HTTP %d", resp.StatusCode)
+ }
+
+ var result struct {
+ Allowed bool `json:"allowed"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return false, fmt.Errorf("decoding response: %w", err)
+ }
+ return result.Allowed, nil
+}
+
// mcpSendResult sends a successful JSON-RPC response.
func mcpSendResult(enc *json.Encoder, id json.RawMessage, result interface{}) {
if err := enc.Encode(mcpResponse{JSONRPC: "2.0", ID: id, Result: result}); err != nil {
diff --git a/cmd/phoenix/mcp_seal_test.go b/cmd/phoenix/mcp_seal_test.go
new file mode 100644
index 0000000..f33a2f4
--- /dev/null
+++ b/cmd/phoenix/mcp_seal_test.go
@@ -0,0 +1,458 @@
+package main
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/phoenixsec/phoenix/internal/crypto"
+)
+
+// withSealKey sets up mcpSealPrivKey for the test and restores it after.
+func withSealKey(t *testing.T, privKey *[32]byte) {
+ t.Helper()
+ old := mcpSealPrivKey
+ mcpSealPrivKey = privKey
+ t.Cleanup(func() { mcpSealPrivKey = old })
+}
+
+// policyCheckHandler returns a handler that serves /v1/policy/check responses.
+// allowPaths maps path → allowed. Paths not in the map return allowed=false.
+func policyCheckHandler(allowPaths map[string]bool) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/v1/policy/check" {
+ path := r.URL.Query().Get("path")
+ allowed := allowPaths[path]
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "path": path,
+ "check": r.URL.Query().Get("check"),
+ "allowed": allowed,
+ })
+ return
+ }
+ http.Error(w, "not found", http.StatusNotFound)
+ }
+}
+
+// --- Tool list tests ---
+
+func TestMCPToolsListWithSealKey(t *testing.T) {
+ kp, _ := crypto.GenerateSealKeyPair()
+ withSealKey(t, &kp.PrivateKey)
+
+ responses := mcpExchange(t, nil,
+ jsonMsg(1, "tools/list", nil),
+ )
+
+ b, _ := json.Marshal(responses[0].Result)
+ var result mcpListToolsResult
+ json.Unmarshal(b, &result)
+
+ if len(result.Tools) != 4 {
+ t.Fatalf("expected 4 tools (incl. unseal), got %d", len(result.Tools))
+ }
+
+ names := map[string]bool{}
+ for _, tool := range result.Tools {
+ names[tool.Name] = true
+ }
+ if !names["phoenix_unseal"] {
+ t.Error("expected phoenix_unseal in tool list when seal key is set")
+ }
+}
+
+func TestMCPToolsListWithoutSealKey(t *testing.T) {
+ withSealKey(t, nil)
+
+ responses := mcpExchange(t, nil,
+ jsonMsg(1, "tools/list", nil),
+ )
+
+ b, _ := json.Marshal(responses[0].Result)
+ var result mcpListToolsResult
+ json.Unmarshal(b, &result)
+
+ if len(result.Tools) != 3 {
+ t.Fatalf("expected 3 tools (no unseal), got %d", len(result.Tools))
+ }
+
+ for _, tool := range result.Tools {
+ if tool.Name == "phoenix_unseal" {
+ t.Error("phoenix_unseal should not appear without seal key")
+ }
+ }
+}
+
+// --- Sealed resolve test ---
+
+func TestMCPToolResolveSealedOutput(t *testing.T) {
+ kp, _ := crypto.GenerateSealKeyPair()
+ withSealKey(t, &kp.PrivateKey)
+
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ sealKey := r.Header.Get("X-Phoenix-Seal-Key")
+ if sealKey == "" {
+ t.Error("expected X-Phoenix-Seal-Key header in sealed mode")
+ }
+ pubKey, _ := crypto.DecodeSealKey(sealKey)
+ env, _ := crypto.SealValue("myapp/api-key", "phoenix://myapp/api-key", "secret123", pubKey)
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "sealed_values": map[string]interface{}{
+ "phoenix://myapp/api-key": env,
+ },
+ })
+ })
+
+ responses := mcpExchange(t, handler,
+ jsonMsg(1, "tools/call", map[string]interface{}{
+ "name": "phoenix_resolve",
+ "arguments": map[string]interface{}{
+ "refs": []string{"phoenix://myapp/api-key"},
+ },
+ }),
+ )
+
+ b, _ := json.Marshal(responses[0].Result)
+ var result mcpCallToolResult
+ json.Unmarshal(b, &result)
+
+ if result.IsError {
+ t.Fatalf("unexpected error: %s", result.Content[0].Text)
+ }
+
+ text := result.Content[0].Text
+ if !strings.Contains(text, "PHOENIX_SEALED:") {
+ t.Fatalf("expected PHOENIX_SEALED: token, got: %s", text)
+ }
+ if strings.Contains(text, "secret123") {
+ t.Fatal("plaintext value should not appear in sealed output")
+ }
+}
+
+// --- Sealed get test ---
+
+func TestMCPToolGetSealedOutput(t *testing.T) {
+ kp, _ := crypto.GenerateSealKeyPair()
+ withSealKey(t, &kp.PrivateKey)
+
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ sealKey := r.Header.Get("X-Phoenix-Seal-Key")
+ pubKey, _ := crypto.DecodeSealKey(sealKey)
+ env, _ := crypto.SealValue("myapp/db-pass", "", "hunter2", pubKey)
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "sealed_value": env,
+ })
+ })
+
+ responses := mcpExchange(t, handler,
+ jsonMsg(1, "tools/call", map[string]interface{}{
+ "name": "phoenix_get",
+ "arguments": map[string]interface{}{
+ "path": "myapp/db-pass",
+ },
+ }),
+ )
+
+ b, _ := json.Marshal(responses[0].Result)
+ var result mcpCallToolResult
+ json.Unmarshal(b, &result)
+
+ if result.IsError {
+ t.Fatalf("unexpected error: %s", result.Content[0].Text)
+ }
+
+ text := result.Content[0].Text
+ if !strings.HasPrefix(text, "PHOENIX_SEALED:") {
+ t.Fatalf("expected PHOENIX_SEALED: prefix, got: %s", text)
+ }
+ if strings.Contains(text, "hunter2") {
+ t.Fatal("plaintext value should not appear in sealed output")
+ }
+}
+
+// --- Unseal tests ---
+
+func TestMCPToolUnsealWithAllowPolicy(t *testing.T) {
+ kp, _ := crypto.GenerateSealKeyPair()
+ withSealKey(t, &kp.PrivateKey)
+
+ env, _ := crypto.SealValue("myapp/secret", "phoenix://myapp/secret", "the-value", &kp.PublicKey)
+ envJSON, _ := json.Marshal(env)
+ sealedToken := "PHOENIX_SEALED:" + base64.StdEncoding.EncodeToString(envJSON)
+
+ handler := policyCheckHandler(map[string]bool{"myapp/secret": true})
+
+ responses := mcpExchange(t, handler,
+ jsonMsg(1, "tools/call", map[string]interface{}{
+ "name": "phoenix_unseal",
+ "arguments": map[string]interface{}{
+ "sealed": sealedToken,
+ },
+ }),
+ )
+
+ b, _ := json.Marshal(responses[0].Result)
+ var result mcpCallToolResult
+ json.Unmarshal(b, &result)
+
+ if result.IsError {
+ t.Fatalf("unexpected error: %s", result.Content[0].Text)
+ }
+ if result.Content[0].Text != "the-value" {
+ t.Fatalf("got %q, want %q", result.Content[0].Text, "the-value")
+ }
+}
+
+func TestMCPToolUnsealDeniedByServer(t *testing.T) {
+ kp, _ := crypto.GenerateSealKeyPair()
+ withSealKey(t, &kp.PrivateKey)
+
+ env, _ := crypto.SealValue("myapp/secret", "phoenix://myapp/secret", "the-value", &kp.PublicKey)
+ envJSON, _ := json.Marshal(env)
+ sealedToken := "PHOENIX_SEALED:" + base64.StdEncoding.EncodeToString(envJSON)
+
+ // Server says allow_unseal=false for this path
+ handler := policyCheckHandler(map[string]bool{})
+
+ responses := mcpExchange(t, handler,
+ jsonMsg(1, "tools/call", map[string]interface{}{
+ "name": "phoenix_unseal",
+ "arguments": map[string]interface{}{
+ "sealed": sealedToken,
+ },
+ }),
+ )
+
+ b, _ := json.Marshal(responses[0].Result)
+ var result mcpCallToolResult
+ json.Unmarshal(b, &result)
+
+ if !result.IsError {
+ t.Fatal("expected error when server denies allow_unseal")
+ }
+ if !strings.Contains(result.Content[0].Text, "Unseal denied") {
+ t.Fatalf("expected 'Unseal denied', got: %s", result.Content[0].Text)
+ }
+}
+
+func TestMCPToolUnsealDeniedWrongPath(t *testing.T) {
+ kp, _ := crypto.GenerateSealKeyPair()
+ withSealKey(t, &kp.PrivateKey)
+
+ env, _ := crypto.SealValue("myapp/secret", "phoenix://myapp/secret", "the-value", &kp.PublicKey)
+ envJSON, _ := json.Marshal(env)
+ sealedToken := "PHOENIX_SEALED:" + base64.StdEncoding.EncodeToString(envJSON)
+
+ // Server allows "other/secret" but not "myapp/secret"
+ handler := policyCheckHandler(map[string]bool{"other/secret": true})
+
+ responses := mcpExchange(t, handler,
+ jsonMsg(1, "tools/call", map[string]interface{}{
+ "name": "phoenix_unseal",
+ "arguments": map[string]interface{}{
+ "sealed": sealedToken,
+ },
+ }),
+ )
+
+ b, _ := json.Marshal(responses[0].Result)
+ var result mcpCallToolResult
+ json.Unmarshal(b, &result)
+
+ if !result.IsError {
+ t.Fatal("expected error when path doesn't match allow_unseal")
+ }
+ if !strings.Contains(result.Content[0].Text, "Unseal denied") {
+ t.Fatalf("expected 'Unseal denied', got: %s", result.Content[0].Text)
+ }
+}
+
+func TestMCPToolUnsealTamperedEnvelope(t *testing.T) {
+ kp, _ := crypto.GenerateSealKeyPair()
+ withSealKey(t, &kp.PrivateKey)
+
+ env, _ := crypto.SealValue("myapp/secret", "phoenix://myapp/secret", "the-value", &kp.PublicKey)
+ envJSON, _ := json.Marshal(env)
+
+ // Tamper with the ciphertext
+ tampered := strings.Replace(string(envJSON), env.Ciphertext[:4], "XXXX", 1)
+ sealedToken := "PHOENIX_SEALED:" + base64.StdEncoding.EncodeToString([]byte(tampered))
+
+ handler := policyCheckHandler(map[string]bool{"myapp/secret": true})
+
+ responses := mcpExchange(t, handler,
+ jsonMsg(1, "tools/call", map[string]interface{}{
+ "name": "phoenix_unseal",
+ "arguments": map[string]interface{}{
+ "sealed": sealedToken,
+ },
+ }),
+ )
+
+ b, _ := json.Marshal(responses[0].Result)
+ var result mcpCallToolResult
+ json.Unmarshal(b, &result)
+
+ if !result.IsError {
+ t.Fatal("expected error for tampered envelope")
+ }
+ if !strings.Contains(result.Content[0].Text, "Decryption failed") {
+ t.Fatalf("expected 'Decryption failed', got: %s", result.Content[0].Text)
+ }
+}
+
+func TestMCPToolUnsealInvalidToken(t *testing.T) {
+ kp, _ := crypto.GenerateSealKeyPair()
+ withSealKey(t, &kp.PrivateKey)
+
+ responses := mcpExchange(t, nil,
+ jsonMsg(1, "tools/call", map[string]interface{}{
+ "name": "phoenix_unseal",
+ "arguments": map[string]interface{}{
+ "sealed": "not-a-sealed-token",
+ },
+ }),
+ )
+
+ b, _ := json.Marshal(responses[0].Result)
+ var result mcpCallToolResult
+ json.Unmarshal(b, &result)
+
+ if !result.IsError {
+ t.Fatal("expected error for invalid token")
+ }
+ if !strings.Contains(result.Content[0].Text, "must start with PHOENIX_SEALED:") {
+ t.Fatalf("unexpected error: %s", result.Content[0].Text)
+ }
+}
+
+func TestMCPToolUnsealNoSealKey(t *testing.T) {
+ withSealKey(t, nil)
+
+ responses := mcpExchange(t, nil,
+ jsonMsg(1, "tools/call", map[string]interface{}{
+ "name": "phoenix_unseal",
+ "arguments": map[string]interface{}{
+ "sealed": "PHOENIX_SEALED:dGVzdA==",
+ },
+ }),
+ )
+
+ b, _ := json.Marshal(responses[0].Result)
+ var result mcpCallToolResult
+ json.Unmarshal(b, &result)
+
+ if !result.IsError {
+ t.Fatal("expected error when seal key is not loaded")
+ }
+ if !strings.Contains(result.Content[0].Text, "not enabled") {
+ t.Fatalf("unexpected error: %s", result.Content[0].Text)
+ }
+}
+
+// --- Sealed resolve missing ref ---
+
+func TestMCPToolResolveSealedMissingRef(t *testing.T) {
+ kp, _ := crypto.GenerateSealKeyPair()
+ withSealKey(t, &kp.PrivateKey)
+
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Return empty sealed_values — ref is missing
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "sealed_values": map[string]interface{}{},
+ })
+ })
+
+ responses := mcpExchange(t, handler,
+ jsonMsg(1, "tools/call", map[string]interface{}{
+ "name": "phoenix_resolve",
+ "arguments": map[string]interface{}{
+ "refs": []string{"phoenix://myapp/missing"},
+ },
+ }),
+ )
+
+ b, _ := json.Marshal(responses[0].Result)
+ var result mcpCallToolResult
+ json.Unmarshal(b, &result)
+
+ if !result.IsError {
+ t.Fatal("expected error for missing ref")
+ }
+ if !strings.Contains(result.Content[0].Text, "no value returned") {
+ t.Fatalf("expected 'no value returned', got: %s", result.Content[0].Text)
+ }
+}
+
+// --- Backward compatibility ---
+
+func TestMCPToolResolvePlaintextWithoutSealKey(t *testing.T) {
+ withSealKey(t, nil)
+
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("X-Phoenix-Seal-Key") != "" {
+ t.Error("should not send seal header without key")
+ }
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "values": map[string]string{"phoenix://myapp/key": "plain-val"},
+ })
+ })
+
+ responses := mcpExchange(t, handler,
+ jsonMsg(1, "tools/call", map[string]interface{}{
+ "name": "phoenix_resolve",
+ "arguments": map[string]interface{}{
+ "refs": []string{"phoenix://myapp/key"},
+ },
+ }),
+ )
+
+ b, _ := json.Marshal(responses[0].Result)
+ var result mcpCallToolResult
+ json.Unmarshal(b, &result)
+
+ if result.IsError {
+ t.Fatalf("unexpected error: %s", result.Content[0].Text)
+ }
+ if !strings.Contains(result.Content[0].Text, "plain-val") {
+ t.Fatalf("expected plaintext value, got: %s", result.Content[0].Text)
+ }
+ if strings.Contains(result.Content[0].Text, "PHOENIX_SEALED:") {
+ t.Fatal("should not produce sealed tokens without seal key")
+ }
+}
+
+func TestMCPToolGetPlaintextWithoutSealKey(t *testing.T) {
+ withSealKey(t, nil)
+
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("X-Phoenix-Seal-Key") != "" {
+ t.Error("should not send seal header without key")
+ }
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "path": "myapp/key", "value": "plain-val",
+ })
+ })
+
+ responses := mcpExchange(t, handler,
+ jsonMsg(1, "tools/call", map[string]interface{}{
+ "name": "phoenix_get",
+ "arguments": map[string]interface{}{
+ "path": "myapp/key",
+ },
+ }),
+ )
+
+ b, _ := json.Marshal(responses[0].Result)
+ var result mcpCallToolResult
+ json.Unmarshal(b, &result)
+
+ if result.IsError {
+ t.Fatalf("unexpected error: %s", result.Content[0].Text)
+ }
+ if result.Content[0].Text != "plain-val" {
+ t.Fatalf("got %q, want %q", result.Content[0].Text, "plain-val")
+ }
+}
diff --git a/cmd/phoenix/seal_test.go b/cmd/phoenix/seal_test.go
new file mode 100644
index 0000000..ac5218f
--- /dev/null
+++ b/cmd/phoenix/seal_test.go
@@ -0,0 +1,479 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/phoenixsec/phoenix/internal/crypto"
+)
+
+// --- Helper tests ---
+
+func TestLoadSealKeyNoEnv(t *testing.T) {
+ t.Setenv("PHOENIX_SEAL_KEY", "")
+ key, err := loadSealKey()
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if key != nil {
+ t.Fatal("expected nil key when PHOENIX_SEAL_KEY is unset")
+ }
+}
+
+func TestLoadSealKeyFromFile(t *testing.T) {
+ kp, err := crypto.GenerateSealKeyPair()
+ if err != nil {
+ t.Fatal(err)
+ }
+ keyPath := filepath.Join(t.TempDir(), "test.seal.key")
+ if err := os.WriteFile(keyPath, []byte(crypto.EncodeSealKey(&kp.PrivateKey)), 0600); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("PHOENIX_SEAL_KEY", keyPath)
+
+ key, err := loadSealKey()
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if key == nil {
+ t.Fatal("expected non-nil key")
+ }
+ if *key != kp.PrivateKey {
+ t.Fatal("loaded key does not match written key")
+ }
+}
+
+func TestSealHeadersNilKey(t *testing.T) {
+ hdrs := sealHeaders(nil)
+ if hdrs != nil {
+ t.Fatal("expected nil headers for nil key")
+ }
+}
+
+func TestSealHeadersWithKey(t *testing.T) {
+ kp, _ := crypto.GenerateSealKeyPair()
+ hdrs := sealHeaders(&kp.PrivateKey)
+ if hdrs == nil {
+ t.Fatal("expected non-nil headers")
+ }
+ val, ok := hdrs["X-Phoenix-Seal-Key"]
+ if !ok {
+ t.Fatal("missing X-Phoenix-Seal-Key header")
+ }
+ expected := crypto.EncodeSealKey(&kp.PublicKey)
+ if val != expected {
+ t.Fatalf("header = %q, want %q", val, expected)
+ }
+}
+
+func TestDecryptSealedValueRoundTrip(t *testing.T) {
+ kp, _ := crypto.GenerateSealKeyPair()
+ env, err := crypto.SealValue("ns/secret", "phoenix://ns/secret", "hunter2", &kp.PublicKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ val, err := decryptSealedValue(env, &kp.PrivateKey)
+ if err != nil {
+ t.Fatalf("decrypt failed: %v", err)
+ }
+ if val != "hunter2" {
+ t.Fatalf("got %q, want %q", val, "hunter2")
+ }
+}
+
+func TestDecryptSealedValueWrongKey(t *testing.T) {
+ kp1, _ := crypto.GenerateSealKeyPair()
+ kp2, _ := crypto.GenerateSealKeyPair()
+ env, _ := crypto.SealValue("ns/secret", "phoenix://ns/secret", "hunter2", &kp1.PublicKey)
+ _, err := decryptSealedValue(env, &kp2.PrivateKey)
+ if err == nil {
+ t.Fatal("expected error decrypting with wrong key")
+ }
+}
+
+// --- cmdGet sealed tests ---
+
+func TestCmdGetSealed(t *testing.T) {
+ kp, _ := crypto.GenerateSealKeyPair()
+
+ keyPath := filepath.Join(t.TempDir(), "test.seal.key")
+ os.WriteFile(keyPath, []byte(crypto.EncodeSealKey(&kp.PrivateKey)), 0600)
+ t.Setenv("PHOENIX_SEAL_KEY", keyPath)
+
+ withMockServer(t, func(w http.ResponseWriter, r *http.Request) {
+ sealKey := r.Header.Get("X-Phoenix-Seal-Key")
+ if sealKey == "" {
+ t.Error("missing X-Phoenix-Seal-Key header")
+ }
+ pubKey, _ := crypto.DecodeSealKey(sealKey)
+ env, _ := crypto.SealValue("ns/secret", "", "sealed-secret-42", pubKey)
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "sealed_value": env,
+ })
+ }, func() {
+ oldStdout := os.Stdout
+ r, w, _ := os.Pipe()
+ os.Stdout = w
+
+ err := cmdGet([]string{"ns/secret"})
+
+ w.Close()
+ os.Stdout = oldStdout
+ buf := make([]byte, 1024)
+ n, _ := r.Read(buf)
+ output := string(buf[:n])
+
+ if err != nil {
+ t.Fatalf("cmdGet failed: %v", err)
+ }
+ if output != "sealed-secret-42" {
+ t.Fatalf("got %q, want %q", output, "sealed-secret-42")
+ }
+ })
+}
+
+func TestCmdGetPlaintextFallback(t *testing.T) {
+ t.Setenv("PHOENIX_SEAL_KEY", "")
+
+ withMockServer(t, func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("X-Phoenix-Seal-Key") != "" {
+ t.Error("should not send seal header without key")
+ }
+ json.NewEncoder(w).Encode(map[string]string{"value": "plaintext-val"})
+ }, func() {
+ oldStdout := os.Stdout
+ r, w, _ := os.Pipe()
+ os.Stdout = w
+
+ err := cmdGet([]string{"ns/secret"})
+
+ w.Close()
+ os.Stdout = oldStdout
+ buf := make([]byte, 1024)
+ n, _ := r.Read(buf)
+ output := string(buf[:n])
+
+ if err != nil {
+ t.Fatalf("cmdGet failed: %v", err)
+ }
+ if output != "plaintext-val" {
+ t.Fatalf("got %q, want %q", output, "plaintext-val")
+ }
+ })
+}
+
+func TestCmdGetSealedMissingSealedValue(t *testing.T) {
+ kp, _ := crypto.GenerateSealKeyPair()
+
+ keyPath := filepath.Join(t.TempDir(), "test.seal.key")
+ os.WriteFile(keyPath, []byte(crypto.EncodeSealKey(&kp.PrivateKey)), 0600)
+ t.Setenv("PHOENIX_SEAL_KEY", keyPath)
+
+ withMockServer(t, func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(map[string]string{"value": "plaintext"})
+ }, func() {
+ err := cmdGet([]string{"ns/secret"})
+ if err == nil {
+ t.Fatal("expected error when sealed_value is missing")
+ }
+ if !strings.Contains(err.Error(), "sealed_value") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+}
+
+// --- cmdResolve sealed tests ---
+
+func TestCmdResolveSealedSingleRef(t *testing.T) {
+ kp, _ := crypto.GenerateSealKeyPair()
+
+ keyPath := filepath.Join(t.TempDir(), "test.seal.key")
+ os.WriteFile(keyPath, []byte(crypto.EncodeSealKey(&kp.PrivateKey)), 0600)
+ t.Setenv("PHOENIX_SEAL_KEY", keyPath)
+
+ withMockServer(t, func(w http.ResponseWriter, r *http.Request) {
+ sealKey := r.Header.Get("X-Phoenix-Seal-Key")
+ pubKey, _ := crypto.DecodeSealKey(sealKey)
+ env, _ := crypto.SealValue("ns/db-pass", "phoenix://ns/db-pass", "p@ssw0rd", pubKey)
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "sealed_values": map[string]interface{}{
+ "phoenix://ns/db-pass": env,
+ },
+ })
+ }, func() {
+ oldStdout := os.Stdout
+ r, w, _ := os.Pipe()
+ os.Stdout = w
+
+ err := cmdResolve([]string{"phoenix://ns/db-pass"})
+
+ w.Close()
+ os.Stdout = oldStdout
+ buf := make([]byte, 1024)
+ n, _ := r.Read(buf)
+ output := string(buf[:n])
+
+ if err != nil {
+ t.Fatalf("cmdResolve failed: %v", err)
+ }
+ if output != "p@ssw0rd" {
+ t.Fatalf("got %q, want %q", output, "p@ssw0rd")
+ }
+ })
+}
+
+func TestCmdResolveSealedMissingRef(t *testing.T) {
+ kp, _ := crypto.GenerateSealKeyPair()
+
+ keyPath := filepath.Join(t.TempDir(), "test.seal.key")
+ os.WriteFile(keyPath, []byte(crypto.EncodeSealKey(&kp.PrivateKey)), 0600)
+ t.Setenv("PHOENIX_SEAL_KEY", keyPath)
+
+ withMockServer(t, func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "sealed_values": map[string]interface{}{},
+ })
+ }, func() {
+ err := cmdResolve([]string{"phoenix://ns/missing"})
+ if err == nil {
+ t.Fatal("expected error for missing ref in sealed response")
+ }
+ if !strings.Contains(err.Error(), "no value returned") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+}
+
+func TestCmdResolveSealedPartialErrors(t *testing.T) {
+ kp, _ := crypto.GenerateSealKeyPair()
+
+ keyPath := filepath.Join(t.TempDir(), "test.seal.key")
+ os.WriteFile(keyPath, []byte(crypto.EncodeSealKey(&kp.PrivateKey)), 0600)
+ t.Setenv("PHOENIX_SEAL_KEY", keyPath)
+
+ withMockServer(t, func(w http.ResponseWriter, r *http.Request) {
+ sealKey := r.Header.Get("X-Phoenix-Seal-Key")
+ pubKey, _ := crypto.DecodeSealKey(sealKey)
+ env, _ := crypto.SealValue("ns/good", "phoenix://ns/good", "val1", pubKey)
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "sealed_values": map[string]interface{}{
+ "phoenix://ns/good": env,
+ },
+ "errors": map[string]string{
+ "phoenix://ns/bad": "not found",
+ },
+ })
+ }, func() {
+ err := cmdResolve([]string{"phoenix://ns/good", "phoenix://ns/bad"})
+ if err == nil {
+ t.Fatal("expected error for partial failure")
+ }
+ if !strings.Contains(err.Error(), "some references failed") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+}
+
+// --- cmdKeypairGenerate tests ---
+
+func TestCmdKeypairGenerateWritesKeyFile(t *testing.T) {
+ kp, _ := crypto.GenerateSealKeyPair()
+
+ outPath := filepath.Join(t.TempDir(), "test-agent.seal.key")
+
+ withMockServer(t, func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "POST" || !strings.HasPrefix(r.URL.Path, "/v1/keypair") {
+ t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
+ }
+ json.NewEncoder(w).Encode(map[string]string{
+ "agent_name": "test-agent",
+ "seal_public_key": crypto.EncodeSealKey(&kp.PublicKey),
+ "seal_private_key": crypto.EncodeSealKey(&kp.PrivateKey),
+ })
+ }, func() {
+ oldStdout := os.Stdout
+ _, w, _ := os.Pipe()
+ os.Stdout = w
+
+ err := cmdKeypairGenerate([]string{"test-agent", "-o", outPath})
+
+ w.Close()
+ os.Stdout = oldStdout
+
+ if err != nil {
+ t.Fatalf("cmdKeypairGenerate failed: %v", err)
+ }
+
+ data, err := os.ReadFile(outPath)
+ if err != nil {
+ t.Fatalf("reading key file: %v", err)
+ }
+ if string(data) != crypto.EncodeSealKey(&kp.PrivateKey) {
+ t.Fatal("key file content mismatch")
+ }
+
+ info, _ := os.Stat(outPath)
+ if info.Mode().Perm() != 0600 {
+ t.Fatalf("key file mode = %o, want 0600", info.Mode().Perm())
+ }
+ })
+}
+
+func TestCmdKeypairGenerateHardensExistingPerms(t *testing.T) {
+ kp, _ := crypto.GenerateSealKeyPair()
+
+ outPath := filepath.Join(t.TempDir(), "test-agent.seal.key")
+ // Pre-create with insecure permissions
+ os.WriteFile(outPath, []byte("old-key"), 0644)
+
+ withMockServer(t, func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(map[string]string{
+ "agent_name": "test-agent",
+ "seal_public_key": crypto.EncodeSealKey(&kp.PublicKey),
+ "seal_private_key": crypto.EncodeSealKey(&kp.PrivateKey),
+ })
+ }, func() {
+ oldStdout := os.Stdout
+ _, w, _ := os.Pipe()
+ os.Stdout = w
+
+ err := cmdKeypairGenerate([]string{"test-agent", "-o", outPath})
+
+ w.Close()
+ os.Stdout = oldStdout
+
+ if err != nil {
+ t.Fatalf("cmdKeypairGenerate failed: %v", err)
+ }
+
+ info, _ := os.Stat(outPath)
+ if info.Mode().Perm() != 0600 {
+ t.Fatalf("key file mode = %o, want 0600 (should harden existing file)", info.Mode().Perm())
+ }
+ })
+}
+
+func TestCmdKeypairGenerateForceFlag(t *testing.T) {
+ kp, _ := crypto.GenerateSealKeyPair()
+ var gotForce bool
+
+ outPath := filepath.Join(t.TempDir(), "test.seal.key")
+
+ withMockServer(t, func(w http.ResponseWriter, r *http.Request) {
+ gotForce = r.URL.Query().Get("force") == "true"
+ json.NewEncoder(w).Encode(map[string]string{
+ "agent_name": "test-agent",
+ "seal_public_key": crypto.EncodeSealKey(&kp.PublicKey),
+ "seal_private_key": crypto.EncodeSealKey(&kp.PrivateKey),
+ })
+ }, func() {
+ oldStdout := os.Stdout
+ _, w, _ := os.Pipe()
+ os.Stdout = w
+
+ err := cmdKeypairGenerate([]string{"test-agent", "--force", "-o", outPath})
+
+ w.Close()
+ os.Stdout = oldStdout
+
+ if err != nil {
+ t.Fatalf("cmdKeypairGenerate failed: %v", err)
+ }
+ if !gotForce {
+ t.Fatal("expected force=true query parameter")
+ }
+ })
+}
+
+func TestCmdKeypairGenerateMissingName(t *testing.T) {
+ t.Setenv("PHOENIX_TOKEN", "test-token")
+ token = "test-token"
+
+ err := cmdKeypairGenerate([]string{})
+ if err == nil {
+ t.Fatal("expected usage error")
+ }
+ if !strings.Contains(err.Error(), "usage:") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestCmdKeypairGenerateDefaultPath(t *testing.T) {
+ kp, _ := crypto.GenerateSealKeyPair()
+
+ tmpHome := t.TempDir()
+ t.Setenv("HOME", tmpHome)
+
+ withMockServer(t, func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(map[string]string{
+ "agent_name": "myagent",
+ "seal_public_key": crypto.EncodeSealKey(&kp.PublicKey),
+ "seal_private_key": crypto.EncodeSealKey(&kp.PrivateKey),
+ })
+ }, func() {
+ oldStdout := os.Stdout
+ _, w, _ := os.Pipe()
+ os.Stdout = w
+
+ err := cmdKeypairGenerate([]string{"myagent"})
+
+ w.Close()
+ os.Stdout = oldStdout
+
+ if err != nil {
+ t.Fatalf("cmdKeypairGenerate failed: %v", err)
+ }
+
+ expectedPath := filepath.Join(tmpHome, ".config", "phoenix", "keys", "myagent.seal.key")
+ info, err := os.Stat(expectedPath)
+ if err != nil {
+ t.Fatalf("key file not found at default path %s: %v", expectedPath, err)
+ }
+ if info.Mode().Perm() != 0600 {
+ t.Fatalf("mode = %o, want 0600", info.Mode().Perm())
+ }
+
+ dirInfo, _ := os.Stat(filepath.Dir(expectedPath))
+ if dirInfo.Mode().Perm() != 0700 {
+ t.Fatalf("keys dir mode = %o, want 0700", dirInfo.Mode().Perm())
+ }
+
+ // Suppress unused import warning
+ _ = fmt.Sprintf("PHOENIX_SEAL_KEY=%s", expectedPath)
+ })
+}
+
+// --- cmdExec env stripping test ---
+
+func TestCmdExecStripsSealKeyEnv(t *testing.T) {
+ t.Setenv("PHOENIX_SEAL_KEY", "/some/key/path")
+ t.Setenv("PHOENIX_TOKEN", "test-token")
+
+ // Build env like cmdExec does
+ var env []string
+ for _, e := range os.Environ() {
+ key := e[:strings.IndexByte(e, '=')]
+ switch key {
+ case "PHOENIX_TOKEN", "PHOENIX_CLIENT_CERT", "PHOENIX_CLIENT_KEY",
+ "PHOENIX_CA_CERT", "PHOENIX_SERVER", "PHOENIX_POLICY",
+ "PHOENIX_SEAL_KEY":
+ continue
+ }
+ env = append(env, e)
+ }
+
+ for _, e := range env {
+ key := e[:strings.IndexByte(e, '=')]
+ if key == "PHOENIX_SEAL_KEY" {
+ t.Fatal("PHOENIX_SEAL_KEY should be stripped from child env")
+ }
+ if key == "PHOENIX_TOKEN" {
+ t.Fatal("PHOENIX_TOKEN should be stripped from child env")
+ }
+ }
+}
diff --git a/docs/api-reference-index.md b/docs/api-reference-index.md
index 8211627..f5cf6ac 100644
--- a/docs/api-reference-index.md
+++ b/docs/api-reference-index.md
@@ -79,6 +79,39 @@ Query params:
- `process_uid` (optional)
- `binary_hash` (optional)
+## Policy checks
+
+| Method | Path | Purpose |
+|---|---|---|
+| GET | `/v1/policy/check` | Query server-authoritative policy for a path |
+
+Query params:
+- `path` (required): secret path to check
+- `check` (required): policy check to perform (currently only `allow_unseal`)
+
+Response:
+```json
+{ "path": "myapp/key", "check": "allow_unseal", "allowed": true }
+```
+
+## Sealed responses
+
+When a request includes the `X-Phoenix-Seal-Key` header (base64-encoded
+X25519 public key), secret-returning endpoints respond with `sealed_values`
+instead of `values`. Each value is a sealed envelope encrypted to the
+provided public key using NaCl box (X25519 + XSalsa20-Poly1305).
+
+Affected endpoints:
+- `GET /v1/secrets/{path}` — returns `sealed_value` instead of `value`
+- `POST /v1/resolve` — returns `sealed_values` map instead of `values`
+
+`POST /v1/resolve?dry_run=true` always returns plaintext `"ok"` status
+values regardless of seal key presence.
+
+All secret-returning responses include `Cache-Control: no-store`.
+
+See [Sealed Responses](sealed-responses.md) for the full guide.
+
## Common error shape
Most failures return:
diff --git a/docs/authentication.md b/docs/authentication.md
new file mode 100644
index 0000000..652ac86
--- /dev/null
+++ b/docs/authentication.md
@@ -0,0 +1,68 @@
+# Phoenix Secrets — Authentication
+
+Phoenix supports bearer tokens, mTLS client certificates, and sealed-response key
+pairs. These can be combined — mTLS for identity, bearer for bootstrap, sealed keys
+for context-safe delivery.
+
+## Bearer tokens
+
+Create an agent with scoped permissions:
+
+```bash
+phoenix agent create deployer \
+ -t "deploy-token-abc" \
+ --acl "myapp/*:read;staging/*:read,write"
+```
+
+The `deployer` agent can read anything under `myapp/` and read/write under `staging/`.
+
+## Mutual TLS (recommended)
+
+Issue a client certificate for an agent:
+
+```bash
+phoenix cert issue deployer -o /etc/phoenix/certs/
+```
+
+This writes `deployer.crt`, `deployer.key`, and `ca.crt`.
+
+```bash
+export PHOENIX_SERVER="https://phoenix.home:9090"
+export PHOENIX_CA_CERT="/etc/phoenix/certs/ca.crt"
+export PHOENIX_CLIENT_CERT="/etc/phoenix/certs/deployer.crt"
+export PHOENIX_CLIENT_KEY="/etc/phoenix/certs/deployer.key"
+
+phoenix get myapp/db-password
+```
+
+No bearer token is required when the cert identifies the agent.
+
+## Sealed-response key pairs
+
+When `PHOENIX_SEAL_KEY` points to a local private key file, Phoenix clients can:
+- send the matching seal public key to the server
+- receive sealed secret payloads instead of plaintext
+- decrypt locally
+
+This is especially important for MCP and multi-agent same-host workflows.
+See [Sealed Responses](sealed-responses.md) and [Multi-Agent Setup](multi-agent-setup.md).
+
+## CLI environment variables
+
+| Variable | Description |
+|----------|-------------|
+| `PHOENIX_SERVER` | Server URL (default: `http://127.0.0.1:9090`) |
+| `PHOENIX_TOKEN` | Bearer token |
+| `PHOENIX_CA_CERT` | CA certificate for TLS verification |
+| `PHOENIX_CLIENT_CERT` | Client certificate for mTLS |
+| `PHOENIX_CLIENT_KEY` | Client key for mTLS |
+| `PHOENIX_SEAL_KEY` | Seal private key file path |
+| `PHOENIX_POLICY` | Policy file path for local `phoenix policy` commands |
+| `PHOENIX_OP_TOKEN_ENV` | Import-only env var name holding 1Password token |
+
+## Related docs
+
+- [Getting Started](getting-started.md)
+- [Policy and Attestation](policy-and-attestation.md)
+- [Sealed Responses](sealed-responses.md)
+- [Admin Token Lifecycle](admin-token-lifecycle.md)
diff --git a/docs/cli-usage.md b/docs/cli-usage.md
new file mode 100644
index 0000000..ef56dbf
--- /dev/null
+++ b/docs/cli-usage.md
@@ -0,0 +1,109 @@
+# Phoenix Secrets — CLI Usage
+
+## Store and retrieve secrets
+
+```bash
+# Store secrets
+phoenix set myapp/db-password -v "hunter2" -d "Production database password"
+phoenix set myapp/api-key -v "sk-live-abc123" -d "Stripe API key"
+
+# Read a secret
+phoenix get myapp/db-password
+
+# List secrets
+phoenix list myapp/
+
+# Delete a secret
+phoenix delete myapp/old-key
+```
+
+## Reference resolution
+
+Use `phoenix://...` references to keep plaintext out of configs and many prompt flows.
+
+```bash
+# Resolve a single reference (outputs raw value, pipeable)
+phoenix resolve phoenix://myapp/db-password
+
+# Resolve multiple references
+phoenix resolve phoenix://myapp/db-password phoenix://myapp/api-key
+```
+
+## Batch resolution API
+
+```bash
+curl -X POST https://localhost:9090/v1/resolve \
+ -H "Authorization: Bearer $TOKEN" \
+ -d '{"refs": ["phoenix://myapp/db-password", "phoenix://myapp/api-key"]}'
+```
+
+Partial failures return per-ref errors without blocking successful resolutions.
+
+## Exec wrapper
+
+```bash
+phoenix exec \
+ --env DB_PASSWORD=phoenix://myapp/db-password \
+ --env STRIPE_KEY=phoenix://myapp/api-key \
+ -- node server.js
+```
+
+Phoenix resolves all references, strips its own credentials from the child
+environment, and then `exec`s into the command. The child gets only the mapped
+secret values, not the broker credentials.
+
+Additional exec flags:
+- `--timeout 5s`
+- `--mask-env`
+- `--output-env `
+
+## Secure secret input
+
+```bash
+echo "my-secret" | phoenix set myapp/api-key --value-stdin
+```
+
+Prefer `--value-stdin` over pasting secrets directly into command arguments.
+
+## Audit log
+
+```bash
+phoenix audit --last 10
+phoenix audit --agent deployer --since 2026-02-26T00:00:00Z
+```
+
+Secret values are never written to the audit log.
+
+## Import and export
+
+### Import from `.env`
+
+```bash
+phoenix import secrets.env --prefix myapp/
+```
+
+### Import from 1Password (one-time migration)
+
+```bash
+export OP_SERVICE_ACCOUNT_TOKEN="ops_..."
+phoenix import --from 1password --vault Engineering --prefix myapp/
+```
+
+Options:
+- `--item ` import a single 1Password item
+- `--dry-run` preview mappings without writing
+- `--skip-existing` skip paths that already exist in Phoenix
+
+### Export as `.env`
+
+```bash
+phoenix export myapp/ --format env > .env
+```
+
+## Related docs
+
+- [Getting Started](getting-started.md)
+- [Authentication](authentication.md)
+- [Policy and Attestation](policy-and-attestation.md)
+- [Integrations](integrations.md)
+- [Sealed Responses](sealed-responses.md)
diff --git a/docs/configuration.md b/docs/configuration.md
new file mode 100644
index 0000000..16136c9
--- /dev/null
+++ b/docs/configuration.md
@@ -0,0 +1,119 @@
+# Phoenix Secrets — Configuration and Operations
+
+## Configuration reference
+
+The server reads a JSON config file. `config.example.json` is a starter template.
+
+| Field | Description | Default |
+|-------|-------------|---------|
+| `server.listen` | Bind address | `127.0.0.1:9090` |
+| `store.path` | Encrypted store file | `/data/store.json` |
+| `store.master_key` | Master key file | `/data/master.key` |
+| `store.backend` | Secret backend (`file` or `1password`) | `file` |
+| `acl.path` | ACL definition file | `/data/acl.json` |
+| `audit.path` | Audit log file | `/data/audit.log` |
+| `auth.bearer.enabled` | Allow bearer token auth | `true` |
+| `auth.mtls.enabled` | Enable mTLS | `false` |
+| `auth.mtls.require` | Reject connections without client cert | `false` |
+| `policy.path` | Attestation policy file (optional) | — |
+| `attestation.nonce.enabled` | Enable nonce challenge-response | `false` |
+| `attestation.nonce.max_age` | Nonce TTL | `30s` |
+| `attestation.token.enabled` | Enable short-lived token minting | `false` |
+| `attestation.token.ttl` | Token lifetime | `15m` |
+| `attestation.local_agent.enabled` | Enable local Unix-socket attestation agent | `false` |
+| `attestation.local_agent.socket_path` | Unix socket path when enabled | — |
+| `onepassword.vault` | 1Password vault name | — |
+| `onepassword.service_account_token_env` | Token env var name | `OP_SERVICE_ACCOUNT_TOKEN` |
+| `onepassword.cache_ttl` | Runtime read/list cache duration | `60s` |
+
+## 1Password runtime backend (broker mode, read-only)
+
+Phoenix can broker access to secrets stored in 1Password:
+
+```json
+{
+ "store": {
+ "backend": "1password"
+ },
+ "onepassword": {
+ "vault": "Engineering",
+ "service_account_token_env": "OP_SERVICE_ACCOUNT_TOKEN",
+ "cache_ttl": "60s"
+ }
+}
+```
+
+Behavior:
+- `GET/resolve/list` go through Phoenix ACL + attestation + audit, then read from 1Password
+- `set/delete` are blocked
+- path mapping: `phoenix://myapp/api-key` -> `op://Engineering/myapp/api-key`
+
+## Architecture summary
+
+```text
++-----------+ phoenix:// +---------+ AES-256-GCM +-------+
+| Agent / | ---- resolve ----> | Phoenix | ---- envelope ----> | Store |
+| Tool | (mTLS/ACL/ | Server | encryption | (JSON)|
++-----------+ attestation) +---------+ +-------+
+ |
+ v
+ +---------+
+ | Audit |
+ | Log |
+ +---------+
+```
+
+Authentication flow:
+- request
+- mTLS cert verification (if available)
+- bearer fallback (if allowed)
+- ACL authorization
+- attestation policy check
+- secret access
+- audit log write
+
+## Docker (planned)
+
+> Docker images are not yet published. The examples below show the intended usage
+> for when `phoenixsecdev/phoenix` is available on Docker Hub. For now, build from
+> source or use the install script.
+
+```bash
+docker pull phoenixsecdev/phoenix:latest
+
+# First run: initialize the data directory
+docker run --rm -v phoenix-data:/data phoenixsecdev/phoenix:latest --init /data
+
+# Start the server
+docker run -d --name phoenix \
+ -v phoenix-data:/data \
+ -p 9090:9090 \
+ --restart unless-stopped \
+ phoenixsecdev/phoenix:latest
+```
+
+Docker Compose example:
+
+```yaml
+services:
+ phoenix:
+ image: phoenixsecdev/phoenix:latest
+ ports:
+ - "9090:9090"
+ volumes:
+ - phoenix-data:/data
+ restart: unless-stopped
+
+volumes:
+ phoenix-data:
+```
+
+**Important:** The generated config defaults to `127.0.0.1:9090`. For Docker port
+mapping to work, set `server.listen` to `0.0.0.0:9090` in the config.
+
+## Related docs
+
+- [Getting Started](getting-started.md)
+- [Authentication](authentication.md)
+- [Key Management](key-management.md)
+- [API Reference Index](api-reference-index.md)
diff --git a/docs/getting-started.md b/docs/getting-started.md
new file mode 100644
index 0000000..fcba92e
--- /dev/null
+++ b/docs/getting-started.md
@@ -0,0 +1,117 @@
+# Phoenix Secrets — Getting Started
+
+First-run guide: install, initialize, start the server, store your first secret.
+
+## Requirements
+
+- Go 1.25+ (if building from source)
+- Linux, macOS, or Windows
+- No external runtime dependencies (no database, no cloud KMS, no Redis)
+
+## Install from GitHub Releases
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/phoenixsec-dev/phoenix/main/scripts/install.sh | sh
+```
+
+Options:
+- `PHOENIX_VERSION=vX.Y.Z` pin a specific release
+- `INSTALL_DIR=/custom/bin` choose install path
+
+## Build from source
+
+```bash
+git clone https://github.com/phoenixsec-dev/phoenix.git
+cd phoenix
+go build -o bin/ ./cmd/...
+```
+
+This produces:
+- `bin/phoenix` — CLI client
+- `bin/phoenix-server` — API server
+
+## Initialize
+
+```bash
+phoenix-server --init /data/phoenix
+```
+
+This generates:
+- master encryption key (`master.key`, mode `0600`)
+- admin bearer token — **save this, it is only shown once**
+- internal CA certificate and key
+- server TLS certificate (SANs: `localhost`, `127.0.0.1`)
+- default configuration file
+
+### Admin token handling
+
+1. Store it in a password manager or secure vault immediately.
+2. Use it as a bootstrap credential only — create scoped agents for real workloads.
+3. Remove it from your shell env after bootstrap (`unset PHOENIX_TOKEN`).
+4. See [Admin Token Lifecycle](admin-token-lifecycle.md) for the full lifecycle.
+
+### Deploying on a LAN?
+
+The default server cert only covers `localhost` and `127.0.0.1`. If other machines need to reach this server, you need a cert that includes the server's LAN IP or hostname.
+
+```bash
+# Edit config.json: set server.listen to "0.0.0.0:9090"
+# Then re-issue a server cert with the correct SANs:
+phoenix cert issue phoenix-server -o /data/phoenix/
+```
+
+Or put Phoenix behind a reverse proxy that terminates TLS.
+
+For full multi-host deployment, see [LAN Deployment](lan-deployment.md).
+
+## Verify file permissions
+
+```bash
+chmod 700 /data/phoenix
+chmod 600 /data/phoenix/master.key /data/phoenix/ca.key /data/phoenix/server.key
+```
+
+## Start the server
+
+```bash
+./bin/phoenix-server --config /data/phoenix/config.json
+```
+
+Example startup output:
+
+```text
+Phoenix server starting on 127.0.0.1:9090
+ Store: /data/phoenix/store.json (0 secrets)
+ Key provider: file
+ ACL: /data/phoenix/acl.json (1 agents)
+ Audit: /data/phoenix/audit.log
+ mTLS: enabled (require=false)
+ Bearer: true
+```
+
+> **Binding to all interfaces:** The default listen address is `127.0.0.1:9090`.
+> To accept connections from other hosts, set `server.listen` to `0.0.0.0:9090`.
+> Only do this with authentication and network controls in place.
+
+## First secret operations
+
+```bash
+export PHOENIX_SERVER="https://localhost:9090"
+export PHOENIX_TOKEN=""
+export PHOENIX_CA_CERT="/data/phoenix/ca.crt"
+
+phoenix set myapp/api-key -v "sk-live-abc123" -d "Stripe API key"
+phoenix get myapp/api-key
+```
+
+For the full command reference (`set`, `get`, `list`, `delete`, `resolve`, `exec`,
+`import`, `export`, `audit`), see [CLI Usage](cli-usage.md).
+
+## Next steps
+
+- [CLI Usage](cli-usage.md) — full command reference
+- [Authentication](authentication.md) — bearer tokens, mTLS, sealed key pairs
+- [Policy and Attestation](policy-and-attestation.md) — per-path security rules
+- [LAN Deployment](lan-deployment.md) — multi-host setup
+- [Configuration](configuration.md) — server config reference and Docker
+- [Sealed Responses](sealed-responses.md) — context-safe encrypted delivery
diff --git a/docs/integrations.md b/docs/integrations.md
new file mode 100644
index 0000000..ef3d29c
--- /dev/null
+++ b/docs/integrations.md
@@ -0,0 +1,127 @@
+# Phoenix Secrets — Integrations
+
+## MCP server (Claude Code / Claude Desktop)
+
+Phoenix includes a built-in MCP server.
+
+Transport options:
+- `phoenix mcp-server` → stdio JSON-RPC
+- `phoenix mcp-server --http :8080 --mcp-token ` → Streamable HTTP on `/mcp`
+
+Example Claude MCP config:
+
+```json
+{
+ "mcpServers": {
+ "phoenix": {
+ "command": "phoenix",
+ "args": ["mcp-server"],
+ "env": {
+ "PHOENIX_SERVER": "https://phoenix:9090",
+ "PHOENIX_TOKEN": "..."
+ }
+ }
+ }
+}
+```
+
+Streamable HTTP mode example:
+
+```bash
+export PHOENIX_SERVER="https://phoenix:9090"
+export PHOENIX_TOKEN=""
+export PHOENIX_MCP_TOKEN=""
+phoenix mcp-server --http 127.0.0.1:8080
+```
+
+Tool identity headers let policy control which MCP tools may access which paths.
+
+> By default, `phoenix_get` and `phoenix_resolve` can return plaintext tool output.
+> With sealed mode (`PHOENIX_SEAL_KEY`) they return opaque `PHOENIX_SEALED:` tokens
+> instead. See [Sealed Responses](sealed-responses.md).
+
+## Claude Code skill
+
+Phoenix includes a reusable skill at `phoenix-skill/SKILL.md` for command-driven
+integration without running MCP mode.
+
+## OpenClaw exec backend
+
+Configure Phoenix as an external secrets provider through OpenClaw's exec backend:
+
+```json
+{
+ "secrets": {
+ "providers": {
+ "phoenix": {
+ "type": "exec",
+ "command": "phoenix",
+ "args": ["resolve"]
+ }
+ }
+ }
+}
+```
+
+Use SecretRefs backed by Phoenix:
+
+```yaml
+api_keys:
+ openai: ${{ secrets.phoenix.phoenix://myapp/openai-key }}
+ anthropic: ${{ secrets.phoenix.phoenix://myapp/anthropic-key }}
+```
+
+Set Phoenix credentials for the OpenClaw process:
+
+```bash
+export PHOENIX_SERVER=https://phoenix:9090
+export PHOENIX_TOKEN=openclaw-agent-token
+# Or use mTLS:
+export PHOENIX_CA_CERT=/etc/phoenix/ca.crt
+export PHOENIX_CLIENT_CERT=/etc/phoenix/openclaw.crt
+export PHOENIX_CLIENT_KEY=/etc/phoenix/openclaw.key
+```
+
+Validate before deploying:
+
+```bash
+phoenix verify --dry-run gateway-config.yaml
+phoenix policy test --agent openclaw --ip 10.0.0.5 myapp/openai-key
+```
+
+## Python SDK
+
+```bash
+pip install phoenix-secrets
+```
+
+```python
+from phoenix_secrets import PhoenixClient
+
+client = PhoenixClient()
+api_key = client.resolve("phoenix://myapp/api-key")
+result = client.resolve_batch([
+ "phoenix://myapp/openai-key",
+ "phoenix://myapp/db-password",
+])
+check = client.verify(["phoenix://myapp/api-key"])
+client.health()
+```
+
+## Direct API
+
+```bash
+curl -X POST https://phoenix:9090/v1/resolve \
+ -H "Authorization: Bearer $TOKEN" \
+ -d '{"refs": ["phoenix://myapp/api-key"]}'
+
+curl https://phoenix:9090/v1/secrets/myapp/api-key \
+ -H "Authorization: Bearer $TOKEN"
+```
+
+## Related docs
+
+- [Sealed Responses](sealed-responses.md)
+- [Multi-Agent Setup](multi-agent-setup.md)
+- [API Reference Index](api-reference-index.md)
+- [Runnable Examples](../examples/README.md)
diff --git a/docs/key-management.md b/docs/key-management.md
new file mode 100644
index 0000000..ff8ae55
--- /dev/null
+++ b/docs/key-management.md
@@ -0,0 +1,69 @@
+# Phoenix Secrets — Key Management
+
+## Master key rotation
+
+Rotate the master encryption key without downtime:
+
+```bash
+phoenix rotate-master
+```
+
+Phoenix generates a new master key, re-wraps namespace DEKs, and persists the
+change atomically. If anything fails mid-rotation, the two-phase commit path
+recovers automatically.
+
+## Master key passphrase protection
+
+Phoenix can protect the master key file with a passphrase using Argon2id and
+AES-256-GCM.
+
+### Initialize with a passphrase
+
+```bash
+phoenix-server --init /data/phoenix --passphrase "my-strong-passphrase"
+```
+
+### Providing the passphrase at boot
+
+```bash
+# 1. Pipe from stdin
+echo "my-passphrase" | phoenix-server --config /data/config.json --passphrase-stdin
+
+# 2. Environment variable
+PHOENIX_MASTER_PASSPHRASE="my-passphrase" phoenix-server --config /data/config.json
+
+# 3. Interactive TTY prompt
+phoenix-server --config /data/config.json
+```
+
+### Add or change passphrase on an existing deployment
+
+```bash
+phoenix-server --protect-key --config /data/config.json
+```
+
+Enter an empty new passphrase to remove protection.
+
+> **Warning:** If you lose the passphrase, you lose your secrets.
+
+## Emergency access
+
+Break-glass offline secret retrieval when the server is down:
+
+```bash
+phoenix emergency get myapp/db-password --data-dir /data/phoenix
+```
+
+For automation, use `--confirm` to skip the interactive prompt.
+
+Emergency mode:
+- reads `store.json` and `master.key` directly from disk
+- supports a single secret only
+- requires explicit confirmation
+- logs the access to `audit.log` with agent `emergency-local`
+
+## Related docs
+
+- [Getting Started](getting-started.md)
+- [Configuration and Operations](configuration.md)
+- [Threat Model](threat-model.md)
diff --git a/docs/lan-deployment.md b/docs/lan-deployment.md
new file mode 100644
index 0000000..302696d
--- /dev/null
+++ b/docs/lan-deployment.md
@@ -0,0 +1,258 @@
+# Phoenix Secrets — LAN Deployment
+
+Deploy one Phoenix server serving multiple machines and agents on a local network.
+
+This guide covers the topology, server setup, certificate distribution, agent
+provisioning, and verification for a multi-host deployment. It is written so that
+an agent (or a human) can execute it end-to-end with no prior Phoenix experience.
+
+## Topology
+
+```text
+ LAN (e.g. 192.168.1.0/24)
+ |
+ +--------------+--------------+
+ | | |
+ [Phoenix Server] [Host A] [Host B] ... [Host N]
+ 192.168.1.10 agent-a agent-b agent-n
+ port 9090 phoenix CLI phoenix CLI phoenix CLI
+ mTLS cert mTLS cert mTLS cert
+```
+
+One server, N clients. Each client machine runs the `phoenix` CLI (and optionally
+the MCP server) with its own identity. The server handles all storage, encryption,
+ACL, and audit.
+
+## Prerequisites
+
+- Phoenix installed on the server and all client machines
+- Network connectivity from clients to server on port 9090 (or your chosen port)
+- SSH or file transfer access to distribute certificates
+
+## 1. Initialize the server
+
+On the server machine:
+
+```bash
+phoenix-server --init /data/phoenix
+```
+
+Save the admin token. You will need it for the next steps.
+
+### Bind to the LAN interface
+
+Edit `/data/phoenix/config.json`:
+
+```json
+{
+ "server": {
+ "listen": "0.0.0.0:9090"
+ }
+}
+```
+
+### Issue a server cert with LAN SANs
+
+The default cert only covers `localhost`. Re-issue it with the server's LAN IP
+and any hostnames clients will use:
+
+```bash
+phoenix cert issue phoenix-server \
+ --san 192.168.1.10 \
+ --san phoenix.internal \
+ -o /data/phoenix/
+```
+
+### Start the server
+
+```bash
+phoenix-server --config /data/phoenix/config.json
+```
+
+## 2. Create agent identities
+
+For each client machine or agent, create an identity with scoped ACLs. Still on
+the server (or any machine with admin credentials):
+
+```bash
+export PHOENIX_SERVER="https://192.168.1.10:9090"
+export PHOENIX_TOKEN=""
+export PHOENIX_CA_CERT="/data/phoenix/ca.crt"
+
+# Create agents with least-privilege ACLs
+phoenix agent create host-a-builder \
+ -t "$(openssl rand -hex 32)" \
+ --acl "build/*:read;ci/*:read"
+
+phoenix agent create host-b-deployer \
+ -t "$(openssl rand -hex 32)" \
+ --acl "deploy/*:read;infra/*:read"
+```
+
+## 3. Issue mTLS certificates
+
+Issue a client cert per agent:
+
+```bash
+phoenix cert issue host-a-builder -o /tmp/certs/host-a/
+phoenix cert issue host-b-deployer -o /tmp/certs/host-b/
+```
+
+Each command produces:
+- `.crt` — client certificate
+- `.key` — client private key
+- `ca.crt` — CA certificate (same for all agents)
+
+## 4. Distribute certificates to client machines
+
+Copy the cert bundle to each client. For example with `scp`:
+
+```bash
+# To Host A
+scp /tmp/certs/host-a/host-a-builder.crt user@192.168.1.20:/etc/phoenix/certs/
+scp /tmp/certs/host-a/host-a-builder.key user@192.168.1.20:/etc/phoenix/certs/
+scp /data/phoenix/ca.crt user@192.168.1.20:/etc/phoenix/certs/
+
+# To Host B
+scp /tmp/certs/host-b/host-b-deployer.crt user@192.168.1.21:/etc/phoenix/certs/
+scp /tmp/certs/host-b/host-b-deployer.key user@192.168.1.21:/etc/phoenix/certs/
+scp /data/phoenix/ca.crt user@192.168.1.21:/etc/phoenix/certs/
+```
+
+On each client machine, lock down permissions (`chmod 700` on the directory,
+`chmod 600` on key files).
+
+## 5. Configure client machines
+
+On each client host, set environment variables for the agent. Add to the agent's
+shell profile, systemd unit, or MCP config:
+
+```bash
+export PHOENIX_SERVER="https://192.168.1.10:9090"
+export PHOENIX_CA_CERT="/etc/phoenix/certs/ca.crt"
+export PHOENIX_CLIENT_CERT="/etc/phoenix/certs/host-a-builder.crt"
+export PHOENIX_CLIENT_KEY="/etc/phoenix/certs/host-a-builder.key"
+```
+
+With mTLS configured, no bearer token is needed — the cert identifies the agent.
+
+### MCP configuration (per agent)
+
+If the agent uses MCP (e.g., Claude Code), configure it in the agent's MCP settings:
+
+```json
+{
+ "mcpServers": {
+ "phoenix": {
+ "command": "phoenix",
+ "args": ["mcp-server"],
+ "env": {
+ "PHOENIX_SERVER": "https://192.168.1.10:9090",
+ "PHOENIX_CA_CERT": "/etc/phoenix/certs/ca.crt",
+ "PHOENIX_CLIENT_CERT": "/etc/phoenix/certs/host-a-builder.crt",
+ "PHOENIX_CLIENT_KEY": "/etc/phoenix/certs/host-a-builder.key"
+ }
+ }
+ }
+}
+```
+
+## 6. Add sealed key pairs (optional but recommended)
+
+For context-safe delivery, generate a seal key pair per agent:
+
+```bash
+# On the server or admin machine
+phoenix keypair generate host-a-builder -o /tmp/keys/host-a/
+phoenix keypair generate host-b-deployer -o /tmp/keys/host-b/
+
+# Distribute seal keys to each host
+scp /tmp/keys/host-a/host-a-builder.seal.key user@192.168.1.20:/etc/phoenix/keys/
+scp /tmp/keys/host-b/host-b-deployer.seal.key user@192.168.1.21:/etc/phoenix/keys/
+```
+
+Then add to each agent's environment:
+
+```bash
+export PHOENIX_SEAL_KEY="/etc/phoenix/keys/host-a-builder.seal.key"
+```
+
+## 7. Apply attestation policy (optional)
+
+Create a policy file that uses the LAN topology — lock paths to specific IPs,
+require mTLS, require sealed delivery for sensitive namespaces:
+
+```json
+{
+ "attestation": {
+ "dev/*": {
+ "require_mtls": false
+ },
+ "build/*": {
+ "require_mtls": true,
+ "source_ip": ["192.168.1.0/24"]
+ },
+ "production/*": {
+ "require_mtls": true,
+ "require_sealed": true,
+ "deny_bearer": true,
+ "source_ip": ["192.168.1.21"]
+ }
+ }
+}
+```
+
+Save as `/data/phoenix/policy.json`, add `"policy": {"path": "/data/phoenix/policy.json"}`
+to the server config, and restart.
+
+See [Policy and Attestation](policy-and-attestation.md) for the full reference
+on available policy fields and testing with `phoenix policy test`.
+
+## 8. Verify the deployment
+
+From each client machine:
+
+```bash
+# Test connectivity and auth
+phoenix status
+
+# Store a test secret (from admin)
+phoenix set build/test-key -v "test-value" -d "deployment test"
+
+# Resolve from the appropriate agent
+phoenix resolve phoenix://build/test-key
+
+# Verify access control — this should fail for agents without build/* access
+phoenix get build/test-key
+
+# Check audit trail (from admin)
+phoenix audit --last 20
+```
+
+### Troubleshooting
+
+| Symptom | Check |
+|---------|-------|
+| `certificate signed by unknown authority` | `PHOENIX_CA_CERT` points to the correct `ca.crt` |
+| `connection refused` | Server is bound to `0.0.0.0:9090`, not `127.0.0.1:9090` |
+| `403 denied` | Agent ACL doesn't include the requested path |
+| `TLS handshake failure` | Server cert SANs include the IP/hostname the client is connecting to |
+| `x509: certificate is valid for localhost, not 192.168.x.x` | Re-issue server cert with `--san ` |
+
+## Scaling notes
+
+- Phoenix is designed for single-server deployments. For the target audience
+ (homelabs, small teams, startups with < 50 machines), one server handles the
+ load comfortably.
+- If you need HA, replication, or multi-region — you've outgrown Phoenix's niche.
+ Consider Vault, Infisical, or a cloud KMS at that scale.
+- Agent cert rotation: issue new certs with `phoenix cert issue`, distribute, and
+ update client configs. Old certs can be revoked via CRL.
+
+## Related docs
+
+- [Getting Started](getting-started.md)
+- [Authentication](authentication.md)
+- [Multi-Agent Setup](multi-agent-setup.md) (same-host multi-agent isolation)
+- [Policy and Attestation](policy-and-attestation.md)
+- [Configuration](configuration.md)
diff --git a/docs/migration-env-to-phoenix.md b/docs/migration-env-to-phoenix.md
index c895f72..24d95d3 100644
--- a/docs/migration-env-to-phoenix.md
+++ b/docs/migration-env-to-phoenix.md
@@ -13,32 +13,28 @@ Identify secrets currently in:
Group by namespace you want in Phoenix (for example `myapp/*`, `staging/*`, `production/*`).
-## 2) Initialize Phoenix and save admin credentials
+## 2) Initialize Phoenix
-```bash
-phoenix-server --init /data/phoenix
-phoenix-server --config /data/phoenix/config.json
-```
+Follow [Getting Started](getting-started.md) to install, initialize, and start
+the server. Come back here once you have a running server and admin token.
-Immediately store the admin token in a secure manager/vault and treat it as bootstrap-only.
-Detailed guidance: [Admin Token Lifecycle](admin-token-lifecycle.md).
+## 3) Import secrets into Phoenix
-Set CLI auth env for admin operations:
+Bulk import from an existing `.env` file:
```bash
-export PHOENIX_SERVER="https://localhost:9090"
-export PHOENIX_TOKEN=""
-export PHOENIX_CA_CERT="/data/phoenix/ca.crt"
+phoenix import secrets.env --prefix myapp/
```
-## 3) Import/store secrets in Phoenix
+Or store individually:
```bash
phoenix set myapp/db-password -v "..." -d "DB password"
phoenix set myapp/api-key -v "..." -d "API key"
```
-Optional: use 1Password import path if applicable.
+See [CLI Usage](cli-usage.md) for import options including 1Password migration
+(`--from 1password`), `--dry-run`, and `--skip-existing`.
## 4) Create least-privilege agent identities
@@ -47,7 +43,8 @@ phoenix agent create myapp-runtime -t "runtime-token" --acl "myapp/*:read"
phoenix agent create myapp-deployer -t "deploy-token" --acl "myapp/*:read,write"
```
-For production, prefer mTLS certs and stricter attestation policies.
+For production, prefer mTLS certs over bearer tokens.
+See [Authentication](authentication.md) for mTLS setup.
## 5) Replace plaintext values with references
diff --git a/docs/multi-agent-setup.md b/docs/multi-agent-setup.md
new file mode 100644
index 0000000..cc22a3b
--- /dev/null
+++ b/docs/multi-agent-setup.md
@@ -0,0 +1,109 @@
+# Phoenix Secrets — Multi-Agent Setup
+
+Running multiple AI agents on the same host, each with isolated access and
+sealed key pairs so one agent cannot read another's secrets.
+
+## Scenario
+
+Two agents — `builder` and `deployer` — run on the same machine and share
+a Phoenix server. Each should only see its own secrets, and secret values
+should be encrypted per-agent even in transit.
+
+## Setup
+
+### 1. Create agents with scoped ACLs
+
+```bash
+phoenix agent create builder \
+ -t "$(openssl rand -hex 32)" \
+ --acl "build/*:read"
+
+phoenix agent create deployer \
+ -t "$(openssl rand -hex 32)" \
+ --acl "deploy/*:read;infra/*:read"
+```
+
+### 2. Generate sealed key pairs
+
+Generate a key pair per agent so each gets encrypted responses only it can
+decrypt. See [Sealed Responses](sealed-responses.md) for how this works.
+
+```bash
+phoenix keypair generate builder -o /etc/phoenix/keys/
+phoenix keypair generate deployer -o /etc/phoenix/keys/
+```
+
+### 3. Configure policy
+
+```json
+{
+ "attestation": {
+ "build/*": {
+ "require_sealed": true,
+ "allow_unseal": true
+ },
+ "deploy/*": {
+ "require_sealed": true,
+ "allow_unseal": false
+ },
+ "infra/*": {
+ "require_sealed": true,
+ "allow_unseal": false
+ }
+ }
+}
+```
+
+- `build/*` allows MCP unseal (builder may need values in tool output)
+- `deploy/*` and `infra/*` deny unseal — values can only be consumed via
+ `phoenix exec`
+
+See [Policy and Attestation](policy-and-attestation.md) for the full policy
+reference.
+
+### 4. Configure each agent's environment
+
+**Builder (e.g., Claude Code):**
+
+```bash
+export PHOENIX_SERVER="https://phoenix:9090"
+export PHOENIX_TOKEN=""
+export PHOENIX_SEAL_KEY="/etc/phoenix/keys/builder.seal.key"
+```
+
+**Deployer (e.g., systemd service):**
+
+```ini
+Environment=PHOENIX_SERVER=https://phoenix:9090
+Environment=PHOENIX_TOKEN=
+Environment=PHOENIX_SEAL_KEY=/etc/phoenix/keys/deployer.seal.key
+```
+
+For MCP configuration, add the same env vars to the agent's MCP server
+config. See [Integrations](integrations.md) for the config format.
+
+## What this gives you
+
+- Each agent's responses are encrypted to its own key pair
+- Agents on the same server cannot decrypt each other's sealed responses
+- ACLs restrict which paths each agent can access at all
+- `require_sealed` ensures no agent can fetch plaintext over the wire
+- `allow_unseal` controls whether MCP tool output contains decrypted values
+
+## Verification
+
+```bash
+# Sealed mode works — resolves and prints plaintext after local decryption
+PHOENIX_SEAL_KEY=/etc/phoenix/keys/builder.seal.key \
+ phoenix resolve phoenix://build/api-key
+
+# Cross-agent access denied
+PHOENIX_TOKEN= phoenix get build/api-key
+# Expected: 403 denied
+```
+
+## Related docs
+
+- [Sealed Responses](sealed-responses.md) — how sealed delivery works
+- [Authentication](authentication.md) — mTLS as an alternative to bearer tokens
+- [LAN Deployment](lan-deployment.md) — multi-host variant of this pattern
diff --git a/docs/policy-and-attestation.md b/docs/policy-and-attestation.md
new file mode 100644
index 0000000..51c01e8
--- /dev/null
+++ b/docs/policy-and-attestation.md
@@ -0,0 +1,74 @@
+# Phoenix Secrets — Policy and Attestation
+
+Phoenix evaluates policy per path. Different secrets can require different proof.
+
+## Example: graduated security
+
+```json
+{
+ "attestation": {
+ "dev/*": {
+ "require_mtls": false,
+ "deny_bearer": false
+ },
+ "staging/*": {
+ "require_mtls": true,
+ "source_ip": ["192.168.0.0/24"]
+ },
+ "production/*": {
+ "require_mtls": true,
+ "deny_bearer": true,
+ "source_ip": ["192.168.0.110", "192.168.0.115"],
+ "cert_fingerprint": "sha256:A1B2C3..."
+ }
+ }
+}
+```
+
+- `dev/*` — any valid credential works
+- `staging/*` — must use mTLS and come from the expected network
+- `production/*` — must use a specific certificate from a specific IP, with no bearer fallback
+
+Add the policy file in server config:
+
+```json
+{
+ "policy": {
+ "path": "/data/phoenix/policy.json"
+ }
+}
+```
+
+## Sealed policy controls
+
+Sealed responses add per-path controls such as:
+- `require_sealed`
+- `allow_unseal`
+
+Use them when you want agents to receive encrypted responses by default and avoid
+plaintext MCP/tool output except where explicitly allowed.
+
+See [Sealed Responses](sealed-responses.md) for rollout details.
+
+## Test policies from the CLI
+
+```bash
+export PHOENIX_POLICY="/data/phoenix/policy.json"
+
+# Show requirements for a path
+phoenix policy show production/db-password
+
+# Dry-run an attestation check
+phoenix policy test --agent deployer --ip 192.168.0.110 production/db-password
+```
+
+`phoenix policy test` is a local approximation helper. It is useful for sanity
+checks, but it does not fully simulate live cryptographic proof paths such as
+mTLS handshakes or nonce freshness handling.
+
+## Related docs
+
+- [Authentication](authentication.md)
+- [Sealed Responses](sealed-responses.md)
+- [Threat Model](threat-model.md)
+- [API Reference Index](api-reference-index.md)
diff --git a/docs/release-runbook.md b/docs/release-runbook.md
index 1ff7d4e..7345405 100644
--- a/docs/release-runbook.md
+++ b/docs/release-runbook.md
@@ -31,10 +31,10 @@ Expected from GoReleaser:
After tagged workflow completes:
```bash
-docker pull phoenixsec/phoenix:latest
-docker pull phoenixsec/phoenix:vX.Y.Z
-docker run --rm phoenixsec/phoenix:vX.Y.Z --version
-docker run --rm phoenixsec/phoenix:vX.Y.Z phoenix-server --version
+docker pull phoenixsecdev/phoenix:latest
+docker pull phoenixsecdev/phoenix:vX.Y.Z
+docker run --rm phoenixsecdev/phoenix:vX.Y.Z --version
+docker run --rm phoenixsecdev/phoenix:vX.Y.Z phoenix-server --version
```
## 5) `phoenix-server --init` Smoke (<2 min target)
diff --git a/docs/sealed-responses.md b/docs/sealed-responses.md
new file mode 100644
index 0000000..641688c
--- /dev/null
+++ b/docs/sealed-responses.md
@@ -0,0 +1,249 @@
+# Sealed Responses
+
+Sealed responses add end-to-end encryption between the Phoenix server and
+individual agents. When sealed mode is enabled, secret values are encrypted
+with NaCl box (X25519 + XSalsa20-Poly1305) to the requesting agent's public
+key before leaving the server. Only the agent holding the corresponding
+private key can decrypt.
+
+## What sealed responses protect
+
+- **Network observers** cannot read secret values, even with TLS terminated
+ at a reverse proxy.
+- **Shared-host agents** on the same machine each get values encrypted to
+ their own key pair — one agent cannot read another's responses.
+- **MCP tool output** contains opaque `PHOENIX_SEALED:...` tokens instead
+ of plaintext, so values are not exposed in tool response text.
+
+## What sealed responses do NOT protect
+
+- If an agent's seal private key is compromised, all sealed values sent to
+ that key are compromised.
+- Sealed mode does not protect against a compromised agent process — once
+ decrypted, the agent holds plaintext in memory.
+- Server-side encryption at rest is unchanged. Sealed responses are a
+ transport-layer control, not a storage control.
+
+---
+
+## Key pair generation
+
+Generate an X25519 key pair per agent:
+
+```bash
+phoenix keypair generate myagent
+```
+
+This writes one file:
+- `myagent.seal.key` (private key, base64-encoded 32 bytes, mode `0600`)
+
+The derived public key is printed to stdout for reference (e.g., for
+server-side registration). It is not written to a file.
+
+Override the output directory:
+
+```bash
+phoenix keypair generate myagent -o /etc/phoenix/keys/
+```
+
+### Rotation
+
+Generate a new key pair and reconfigure the agent. Old sealed responses
+cannot be decrypted with the new key, but that is by design — sealed
+responses are ephemeral transport encryption, not archival.
+
+---
+
+## Configuring sealed mode
+
+### CLI
+
+Set `PHOENIX_SEAL_KEY` to the path of the agent's private key file:
+
+```bash
+export PHOENIX_SEAL_KEY="/etc/phoenix/keys/myagent.seal.key"
+```
+
+When set, `phoenix get`, `phoenix resolve`, and `phoenix exec` automatically:
+1. Derive the public key from the private key.
+2. Send the public key in the `X-Phoenix-Seal-Key` request header.
+3. Receive sealed envelopes instead of plaintext values.
+4. Decrypt locally before returning to the caller.
+
+### MCP server
+
+```json
+{
+ "mcpServers": {
+ "phoenix": {
+ "command": "phoenix",
+ "args": ["mcp-server"],
+ "env": {
+ "PHOENIX_SERVER": "https://phoenix:9090",
+ "PHOENIX_TOKEN": "...",
+ "PHOENIX_SEAL_KEY": "/etc/phoenix/keys/myagent.seal.key"
+ }
+ }
+ }
+}
+```
+
+When `PHOENIX_SEAL_KEY` is set in MCP mode:
+- `phoenix_resolve` and `phoenix_get` return `PHOENIX_SEALED:`
+ opaque tokens instead of plaintext.
+- `phoenix_unseal` is added to the tool list, allowing the agent to
+ explicitly decrypt a sealed token when policy permits.
+
+### SDKs
+
+**Go:**
+```go
+client := phoenix.New("https://phoenix:9090", "token")
+client.SetSealKey("/path/to/agent.seal.key")
+
+// Transparent — returns plaintext after local decryption
+val, err := client.Resolve("phoenix://myapp/api-key")
+```
+
+**Python:**
+```bash
+pip install phoenix-secrets[sealed] # installs PyNaCl
+```
+
+```python
+from phoenix_secrets import PhoenixClient
+
+client = PhoenixClient(seal_key_path="/path/to/agent.seal.key")
+# or: client = PhoenixClient() # reads PHOENIX_SEAL_KEY from env
+
+val = client.resolve("phoenix://myapp/api-key")
+```
+
+**TypeScript:**
+```bash
+npm install phoenix-secrets # tweetnacl is an optional dependency
+```
+
+```javascript
+const { PhoenixClient } = require("phoenix-secrets");
+
+const client = new PhoenixClient({
+ sealKeyPath: "/path/to/agent.seal.key",
+});
+const val = await client.resolve("phoenix://myapp/api-key");
+```
+
+All SDKs handle sealed responses transparently — callers see plaintext
+values without code changes.
+
+---
+
+## Policy controls
+
+### `require_sealed`
+
+Force agents to use sealed mode for specific paths. Requests without a
+valid `X-Phoenix-Seal-Key` header are denied:
+
+```json
+{
+ "attestation": {
+ "production/*": {
+ "require_sealed": true
+ }
+ }
+}
+```
+
+### `allow_unseal`
+
+Control whether the MCP `phoenix_unseal` tool can decrypt sealed tokens
+for a given path. When `false` (default), sealed tokens remain opaque and
+the agent must pass them to `phoenix exec` for injection:
+
+```json
+{
+ "attestation": {
+ "production/*": {
+ "require_sealed": true,
+ "allow_unseal": false
+ },
+ "dev/*": {
+ "allow_unseal": true
+ }
+ }
+}
+```
+
+The `allow_unseal` check is server-authoritative — the MCP client queries
+`GET /v1/policy/check?path=&check=allow_unseal` before decrypting.
+
+---
+
+## Wire format
+
+When `X-Phoenix-Seal-Key` is present, the server returns `sealed_values`
+instead of `values`:
+
+```json
+{
+ "sealed_values": {
+ "phoenix://myapp/api-key": {
+ "version": 1,
+ "algorithm": "x25519-xsalsa20-poly1305",
+ "path": "myapp/api-key",
+ "ref": "phoenix://myapp/api-key",
+ "ephemeral_key": "",
+ "nonce": "",
+ "ciphertext": ""
+ }
+ }
+}
+```
+
+Each envelope uses a fresh ephemeral key pair (forward secrecy). The
+decrypted payload contains:
+
+```json
+{
+ "path": "myapp/api-key",
+ "ref": "phoenix://myapp/api-key",
+ "value": "sk-live-abc123",
+ "issued_at": "2026-03-08T12:00:00Z"
+}
+```
+
+Inner `path` and `ref` fields are validated against the outer envelope to
+prevent relabeling attacks.
+
+### Dry-run behavior
+
+`POST /v1/resolve?dry_run=true` always returns plaintext `values` with
+`"ok"` status strings regardless of seal key presence. Dry-run never
+returns sealed envelopes.
+
+---
+
+## MCP sealed token format
+
+In MCP mode, sealed values are returned as opaque tokens:
+
+```
+PHOENIX_SEALED:eyJ2ZXJzaW9uIjoxLC...
+```
+
+The token is the base64-encoded JSON sealed envelope. This format is:
+- Safe to store in environment variables
+- Safe to pass through `phoenix exec --env`
+- Not parseable without the private key
+
+---
+
+## Migration from plaintext to sealed
+
+1. Generate key pairs for each agent: `phoenix keypair generate `
+2. Set `PHOENIX_SEAL_KEY` in each agent's environment.
+3. Test: `phoenix resolve phoenix://test/secret` should work unchanged.
+4. Optionally add `require_sealed: true` to policy for sensitive paths.
+5. Optionally set `allow_unseal: false` for paths that should never be
+ decrypted in MCP tool output.
diff --git a/docs/threat-model.md b/docs/threat-model.md
index 46ba891..a4a22ec 100644
--- a/docs/threat-model.md
+++ b/docs/threat-model.md
@@ -63,6 +63,20 @@ Per-path policy can require combinations of:
- `require_nonce`
- `require_fresh_attestation`
+## Sealed responses
+
+Sealed responses add per-agent transport encryption (NaCl box: X25519 +
+XSalsa20-Poly1305) with fresh ephemeral keys per response. This mitigates
+network eavesdropping past TLS termination, cross-agent response interception
+on shared hosts, MCP tool output leakage, and relabeling attacks.
+
+It does not protect against a compromised agent private key, in-process
+memory access after decryption, or server-side compromise.
+
+For the full sealed responses design, wire format, policy controls
+(`require_sealed`, `allow_unseal`), and SDK integration, see
+[Sealed Responses](sealed-responses.md).
+
## Out of scope / non-goals
- Host root compromise or kernel compromise
diff --git a/examples/README.md b/examples/README.md
index 9b4b886..e013064 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -6,7 +6,7 @@ These examples are end-to-end shell scripts you can run locally.
- Go 1.25+
- `python3`
-- From repo root: `github.com/phoenixsec/phoenix`
+- From repo root: `github.com/phoenixsec-dev/phoenix`
Each script will:
- build `bin/phoenix` + `bin/phoenix-server` if missing
diff --git a/internal/acl/acl.go b/internal/acl/acl.go
index d3b2231..b865da2 100644
--- a/internal/acl/acl.go
+++ b/internal/acl/acl.go
@@ -6,6 +6,7 @@
package acl
import (
+ "encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -73,9 +74,10 @@ type Permission struct {
// Agent represents an authenticated entity with permissions.
type Agent struct {
- Name string `json:"name"`
- TokenHash string `json:"token_hash"`
- Permissions []Permission `json:"permissions"`
+ Name string `json:"name"`
+ TokenHash string `json:"token_hash"`
+ Permissions []Permission `json:"permissions"`
+ SealPublicKey string `json:"seal_public_key,omitempty"`
}
// ACLConfig is the on-disk ACL configuration.
@@ -265,14 +267,16 @@ func (a *ACL) UpdateAgent(name, token string, permissions []Permission) error {
a.mu.Lock()
defer a.mu.Unlock()
- if _, exists := a.config.Agents[name]; !exists {
+ existing, exists := a.config.Agents[name]
+ if !exists {
return ErrAgentNotFound
}
a.config.Agents[name] = Agent{
- Name: name,
- TokenHash: crypto.HashToken(token),
- Permissions: permissions,
+ Name: name,
+ TokenHash: crypto.HashToken(token),
+ Permissions: permissions,
+ SealPublicKey: existing.SealPublicKey,
}
if a.path != "" {
@@ -322,6 +326,48 @@ func (a *ACL) GetAgent(name string) (*Agent, error) {
return &agent, nil
}
+// SetAgentSealKey registers or updates an agent's public seal key.
+// The key must be valid base64 encoding exactly 32 bytes (X25519 public key).
+func (a *ACL) SetAgentSealKey(name string, publicKey string) error {
+ raw, err := base64.StdEncoding.DecodeString(publicKey)
+ if err != nil {
+ return fmt.Errorf("invalid seal key encoding: %w", err)
+ }
+ if len(raw) != 32 {
+ return fmt.Errorf("invalid seal key size: got %d bytes, want 32", len(raw))
+ }
+
+ a.mu.Lock()
+ defer a.mu.Unlock()
+
+ agent, ok := a.config.Agents[name]
+ if !ok {
+ return ErrAgentNotFound
+ }
+
+ agent.SealPublicKey = publicKey
+ a.config.Agents[name] = agent
+
+ if a.path != "" {
+ return a.saveLocked()
+ }
+ return nil
+}
+
+// GetAgentSealKey returns an agent's registered public seal key.
+// Returns ErrAgentNotFound if the agent does not exist.
+// Returns empty string (no error) if the agent has no seal key.
+func (a *ACL) GetAgentSealKey(name string) (string, error) {
+ a.mu.RLock()
+ defer a.mu.RUnlock()
+
+ agent, ok := a.config.Agents[name]
+ if !ok {
+ return "", ErrAgentNotFound
+ }
+ return agent.SealPublicKey, nil
+}
+
// matchPath checks if a glob pattern matches a secret path.
// Supports:
// - "*" matches everything
diff --git a/internal/acl/acl_test.go b/internal/acl/acl_test.go
index dc02954..0b08887 100644
--- a/internal/acl/acl_test.go
+++ b/internal/acl/acl_test.go
@@ -2,6 +2,7 @@ package acl
import (
"fmt"
+ "os"
"sync"
"testing"
@@ -537,3 +538,161 @@ func TestValidatePermissionsNewActions(t *testing.T) {
t.Fatalf("new actions should be valid: %v", err)
}
}
+
+func TestSetAgentSealKey(t *testing.T) {
+ a := NewFromConfig(&ACLConfig{Agents: make(map[string]Agent)})
+ a.AddAgent("test", "tok", []Permission{{Path: "ns/*", Actions: []Action{ActionRead}}})
+
+ // Valid 32-byte key in base64
+ validKey := "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
+
+ if err := a.SetAgentSealKey("test", validKey); err != nil {
+ t.Fatalf("SetAgentSealKey: %v", err)
+ }
+
+ got, err := a.GetAgentSealKey("test")
+ if err != nil {
+ t.Fatalf("GetAgentSealKey: %v", err)
+ }
+ if got != validKey {
+ t.Errorf("seal key = %q, want %q", got, validKey)
+ }
+}
+
+func TestSetAgentSealKeyNotFound(t *testing.T) {
+ a := NewFromConfig(&ACLConfig{Agents: make(map[string]Agent)})
+
+ if err := a.SetAgentSealKey("nonexistent", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="); err != ErrAgentNotFound {
+ t.Errorf("expected ErrAgentNotFound, got %v", err)
+ }
+}
+
+func TestSetAgentSealKeyBadBase64(t *testing.T) {
+ a := NewFromConfig(&ACLConfig{Agents: make(map[string]Agent)})
+ a.AddAgent("test", "tok", []Permission{{Path: "ns/*", Actions: []Action{ActionRead}}})
+
+ if err := a.SetAgentSealKey("test", "not-valid-base64!!!"); err == nil {
+ t.Error("expected error for bad base64")
+ }
+}
+
+func TestSetAgentSealKeyWrongSize(t *testing.T) {
+ a := NewFromConfig(&ACLConfig{Agents: make(map[string]Agent)})
+ a.AddAgent("test", "tok", []Permission{{Path: "ns/*", Actions: []Action{ActionRead}}})
+
+ // 16 bytes instead of 32
+ shortKey := "AAAAAAAAAAAAAAAAAAAAAA=="
+ if err := a.SetAgentSealKey("test", shortKey); err == nil {
+ t.Error("expected error for wrong key size")
+ }
+}
+
+func TestGetAgentSealKeyNoKey(t *testing.T) {
+ a := NewFromConfig(&ACLConfig{Agents: make(map[string]Agent)})
+ a.AddAgent("test", "tok", []Permission{{Path: "ns/*", Actions: []Action{ActionRead}}})
+
+ got, err := a.GetAgentSealKey("test")
+ if err != nil {
+ t.Fatalf("GetAgentSealKey: %v", err)
+ }
+ if got != "" {
+ t.Errorf("expected empty seal key, got %q", got)
+ }
+}
+
+func TestGetAgentSealKeyNotFound(t *testing.T) {
+ a := NewFromConfig(&ACLConfig{Agents: make(map[string]Agent)})
+
+ _, err := a.GetAgentSealKey("nonexistent")
+ if err != ErrAgentNotFound {
+ t.Errorf("expected ErrAgentNotFound, got %v", err)
+ }
+}
+
+func TestSealKeyPersistence(t *testing.T) {
+ dir := t.TempDir()
+ path := dir + "/acl.json"
+
+ a1, _ := New(path)
+ a1.AddAgent("test", "tok", []Permission{{Path: "ns/*", Actions: []Action{ActionRead}}})
+
+ validKey := "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
+ if err := a1.SetAgentSealKey("test", validKey); err != nil {
+ t.Fatalf("SetAgentSealKey: %v", err)
+ }
+
+ // Reload from disk
+ a2, err := New(path)
+ if err != nil {
+ t.Fatalf("reload: %v", err)
+ }
+
+ got, err := a2.GetAgentSealKey("test")
+ if err != nil {
+ t.Fatalf("GetAgentSealKey after reload: %v", err)
+ }
+ if got != validKey {
+ t.Errorf("seal key after reload = %q, want %q", got, validKey)
+ }
+}
+
+func TestSealKeyBackwardCompat(t *testing.T) {
+ // Simulate loading an old ACL config with no seal_public_key field
+ dir := t.TempDir()
+ path := dir + "/acl.json"
+
+ oldConfig := `{"agents":{"legacy":{"name":"legacy","token_hash":"sha256:abc","permissions":[{"path":"ns/*","actions":["list"]}]}}}`
+ if err := os.WriteFile(path, []byte(oldConfig), 0600); err != nil {
+ t.Fatalf("writing old config: %v", err)
+ }
+
+ a, err := New(path)
+ if err != nil {
+ t.Fatalf("loading old config: %v", err)
+ }
+
+ got, err := a.GetAgentSealKey("legacy")
+ if err != nil {
+ t.Fatalf("GetAgentSealKey: %v", err)
+ }
+ if got != "" {
+ t.Errorf("expected empty seal key for old config, got %q", got)
+ }
+}
+
+func TestUpdateAgentPreservesSealKey(t *testing.T) {
+ a := NewFromConfig(&ACLConfig{Agents: make(map[string]Agent)})
+ a.AddAgent("test", "tok1", []Permission{{Path: "ns/*", Actions: []Action{ActionRead}}})
+
+ validKey := "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
+ a.SetAgentSealKey("test", validKey)
+
+ // UpdateAgent with new token and permissions
+ if err := a.UpdateAgent("test", "tok2", []Permission{{Path: "other/*", Actions: []Action{ActionWrite}}}); err != nil {
+ t.Fatalf("UpdateAgent: %v", err)
+ }
+
+ got, err := a.GetAgentSealKey("test")
+ if err != nil {
+ t.Fatalf("GetAgentSealKey after update: %v", err)
+ }
+ if got != validKey {
+ t.Errorf("seal key after UpdateAgent = %q, want %q (should be preserved)", got, validKey)
+ }
+}
+
+func TestSetAgentSealKeyOverwrite(t *testing.T) {
+ a := NewFromConfig(&ACLConfig{Agents: make(map[string]Agent)})
+ a.AddAgent("test", "tok", []Permission{{Path: "ns/*", Actions: []Action{ActionRead}}})
+
+ key1 := "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
+ key2 := "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="
+
+ a.SetAgentSealKey("test", key1)
+ a.SetAgentSealKey("test", key2)
+
+ got, _ := a.GetAgentSealKey("test")
+ if got != key2 {
+ t.Errorf("seal key = %q, want %q (should be overwritten)", got, key2)
+ }
+}
diff --git a/internal/api/api.go b/internal/api/api.go
index 387c6a0..ef72da1 100644
--- a/internal/api/api.go
+++ b/internal/api/api.go
@@ -207,6 +207,9 @@ func (s *Server) routes() {
s.mux.HandleFunc("GET /v1/status", s.handleStatus)
s.mux.HandleFunc("POST /v1/token/mint", s.handleMintToken)
s.mux.HandleFunc("POST /v1/proxy", s.handleProxy)
+ s.mux.HandleFunc("POST /v1/keypair", s.handleGenerateKeyPair)
+ s.mux.HandleFunc("GET /v1/agents/", s.handleAgentSubresource)
+ s.mux.HandleFunc("GET /v1/policy/check", s.handlePolicyCheck)
}
// authInfo contains authentication result and attestation evidence.
@@ -311,10 +314,14 @@ func certFingerprint(raw []byte) string {
// Returns nil if no policy is configured or if the request passes.
// nonceValidated indicates whether a nonce was submitted and validated for this request.
func (s *Server) attest(r *http.Request, path string, info *authInfo, nonceValidated bool) error {
- return s.attestSigned(r, path, info, nonceValidated, false)
+ return s.attestFull(r, path, info, nonceValidated, false, false)
}
func (s *Server) attestSigned(r *http.Request, path string, info *authInfo, nonceValidated, signatureVerified bool) error {
+ return s.attestFull(r, path, info, nonceValidated, signatureVerified, false)
+}
+
+func (s *Server) attestFull(r *http.Request, path string, info *authInfo, nonceValidated, signatureVerified, sealKeyValidated bool) error {
if s.policy == nil {
return nil
}
@@ -328,6 +335,7 @@ func (s *Server) attestSigned(r *http.Request, path string, info *authInfo, nonc
NonceValidated: nonceValidated,
SignatureVerified: signatureVerified,
TokenIssuedAt: info.TokenIssuedAt,
+ SealKeyPresented: sealKeyValidated,
}
return s.policy.Evaluate(path, ctx)
}
@@ -412,6 +420,15 @@ func (s *Server) handleGetSecret(w http.ResponseWriter, r *http.Request) {
path := secretPath(r)
ip := clientIP(r)
+ // Validate seal header early so attestation gets the correct state
+ // for both list mode and single-secret reads.
+ sealKey, sealErr := s.validateSealHeader(r, agentName)
+ if sealErr != nil {
+ jsonError(w, sealErr.Error(), http.StatusBadRequest)
+ return
+ }
+ sealKeyValidated := sealKey != nil
+
// List mode: path is empty or ends with /
if path == "" || strings.HasSuffix(path, "/") {
allPaths, err := s.backend.List(path)
@@ -426,7 +443,7 @@ func (s *Server) handleGetSecret(w http.ResponseWriter, r *http.Request) {
continue
}
// Attestation check: hide paths the caller can't attest for
- if s.attest(r, p, info, false) != nil {
+ if s.attestFull(r, p, info, false, false, sealKeyValidated) != nil {
continue
}
visible = append(visible, p)
@@ -443,8 +460,8 @@ func (s *Server) handleGetSecret(w http.ResponseWriter, r *http.Request) {
return
}
- // Attestation policy check
- if err := s.attest(r, path, info, false); err != nil {
+ // Attestation policy check (with validated seal key state)
+ if err := s.attestFull(r, path, info, false, false, sealKeyValidated); err != nil {
s.logAudit(s.audit.LogDenied(agentName, "read_value", path, ip, "attestation"))
jsonError(w, "attestation required: "+err.Error(), http.StatusForbidden)
return
@@ -466,6 +483,23 @@ func (s *Server) handleGetSecret(w http.ResponseWriter, r *http.Request) {
}
s.logAudit(s.audit.LogAllowed(agentName, "read_value", path, ip))
+ if sealKey != nil {
+ env, err := crypto.SealValue(path, "", secret.Value, sealKey)
+ if err != nil {
+ log.Printf("error sealing secret %q: %v", path, err)
+ jsonError(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Cache-Control", "no-store")
+ jsonOK(w, map[string]interface{}{
+ "path": path,
+ "metadata": secret.Metadata,
+ "sealed_value": env,
+ })
+ return
+ }
+
+ w.Header().Set("Cache-Control", "no-store")
jsonOK(w, secret)
}
@@ -860,6 +894,13 @@ func (s *Server) handleResolve(w http.ResponseWriter, r *http.Request) {
dryRun := r.URL.Query().Get("dry_run") == "true"
+ // Validate seal key header early (before processing refs)
+ sealKey, sealErr := s.validateSealHeader(r, agentName)
+ if sealErr != nil {
+ jsonError(w, sealErr.Error(), http.StatusBadRequest)
+ return
+ }
+
var req resolveRequest
r.Body = http.MaxBytesReader(w, r.Body, MaxRequestBodyBytes)
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -936,6 +977,7 @@ func (s *Server) handleResolve(w http.ResponseWriter, r *http.Request) {
}
values := make(map[string]string)
+ sealedValues := make(map[string]*crypto.SealedEnvelope)
errors := make(map[string]string)
for _, refStr := range req.Refs {
@@ -953,7 +995,7 @@ func (s *Server) handleResolve(w http.ResponseWriter, r *http.Request) {
}
// Attestation policy check (per-ref)
- if err := s.attestSigned(r, path, info, nonceValidated, signatureVerified); err != nil {
+ if err := s.attestFull(r, path, info, nonceValidated, signatureVerified, sealKey != nil); err != nil {
s.logAudit(s.audit.LogDenied(agentName, "resolve", path, ip, "attestation"))
errors[refStr] = "attestation required"
continue
@@ -997,15 +1039,32 @@ func (s *Server) handleResolve(w http.ResponseWriter, r *http.Request) {
}
s.logAudit(s.audit.LogAllowed(agentName, "resolve", path, ip))
- values[refStr] = secret.Value
+
+ if sealKey != nil {
+ env, err := crypto.SealValue(path, refStr, secret.Value, sealKey)
+ if err != nil {
+ log.Printf("error sealing resolved secret %q: %v", path, err)
+ errors[refStr] = "internal error"
+ continue
+ }
+ sealedValues[refStr] = env
+ } else {
+ values[refStr] = secret.Value
+ }
}
- resp := map[string]interface{}{
- "values": values,
+ resp := map[string]interface{}{}
+ if sealKey != nil && !dryRun {
+ resp["sealed_values"] = sealedValues
+ } else {
+ resp["values"] = values
}
if len(errors) > 0 {
resp["errors"] = errors
}
+ if !dryRun {
+ w.Header().Set("Cache-Control", "no-store")
+ }
jsonOK(w, resp)
}
@@ -1155,6 +1214,12 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
if rule.RequireFreshAttestation {
checks = append(checks, "fresh-credential")
}
+ if rule.RequireSealed {
+ checks = append(checks, "sealed-required")
+ }
+ if rule.AllowUnseal {
+ checks = append(checks, "unseal-allowed")
+ }
policySummary[pattern] = strings.Join(checks, " + ")
}
status["policy_rules"] = len(rules)
@@ -1189,6 +1254,43 @@ type mintTokenRequest struct {
BinaryHash string `json:"binary_hash,omitempty"`
}
+// handlePolicyCheck returns whether a specific policy check passes for a path.
+// Query params: path (required), check (required, e.g. "allow_unseal").
+func (s *Server) handlePolicyCheck(w http.ResponseWriter, r *http.Request) {
+ _, err := s.authenticate(r)
+ if err != nil {
+ jsonError(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ path := r.URL.Query().Get("path")
+ check := r.URL.Query().Get("check")
+ if path == "" || check == "" {
+ jsonError(w, "path and check query parameters required", http.StatusBadRequest)
+ return
+ }
+
+ if check != "allow_unseal" {
+ jsonError(w, "unsupported check: "+check, http.StatusBadRequest)
+ return
+ }
+
+ allowed := false
+ if s.policy != nil {
+ rule, _ := s.policy.RuleFor(path)
+ if rule != nil {
+ allowed = rule.AllowUnseal
+ }
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "path": path,
+ "check": check,
+ "allowed": allowed,
+ })
+}
+
// handleMintToken creates a short-lived token for an agent.
func (s *Server) handleMintToken(w http.ResponseWriter, r *http.Request) {
if s.tokens == nil {
@@ -1237,6 +1339,156 @@ func (s *Server) handleMintToken(w http.ResponseWriter, r *http.Request) {
})
}
+// validateSealHeader checks the X-Phoenix-Seal-Key header against the agent's
+// registered public seal key. Returns the decoded 32-byte public key if valid,
+// or nil if no header is present. Returns an error if the header is present but
+// invalid or mismatched.
+func (s *Server) validateSealHeader(r *http.Request, agentName string) (*[32]byte, error) {
+ header := r.Header.Get("X-Phoenix-Seal-Key")
+ if header == "" {
+ return nil, nil
+ }
+
+ pubKey, err := crypto.DecodeSealKey(header)
+ if err != nil {
+ return nil, fmt.Errorf("malformed seal key header: %w", err)
+ }
+
+ registered, err := s.acl.GetAgentSealKey(agentName)
+ if err != nil {
+ return nil, fmt.Errorf("agent lookup failed: %w", err)
+ }
+ if registered == "" {
+ return nil, fmt.Errorf("agent has no registered seal key")
+ }
+ registeredKey, err := crypto.DecodeSealKey(registered)
+ if err != nil {
+ return nil, fmt.Errorf("registered seal key is malformed: %w", err)
+ }
+ if *registeredKey != *pubKey {
+ return nil, fmt.Errorf("seal key does not match registered key")
+ }
+
+ return pubKey, nil
+}
+
+// generateKeyPairRequest is the JSON body for keypair generation.
+type generateKeyPairRequest struct {
+ AgentName string `json:"agent_name"`
+}
+
+func (s *Server) handleGenerateKeyPair(w http.ResponseWriter, r *http.Request) {
+ agentName, err := s.authenticate(r)
+ if err != nil {
+ jsonError(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ if err := s.acl.Authorize(agentName, "keypair", acl.ActionAdmin); err != nil {
+ jsonError(w, "access denied: admin required", http.StatusForbidden)
+ return
+ }
+
+ var req generateKeyPairRequest
+ r.Body = http.MaxBytesReader(w, r.Body, MaxRequestBodyBytes)
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ jsonError(w, "invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if req.AgentName == "" {
+ jsonError(w, "agent_name is required", http.StatusBadRequest)
+ return
+ }
+
+ // Verify agent exists
+ _, err = s.acl.GetAgent(req.AgentName)
+ if err != nil {
+ jsonError(w, "agent not found", http.StatusNotFound)
+ return
+ }
+
+ // Check if agent already has a seal key
+ existing, _ := s.acl.GetAgentSealKey(req.AgentName)
+ force := r.URL.Query().Get("force") == "true"
+ if existing != "" && !force {
+ jsonError(w, "agent already has a seal key (use ?force=true to rotate)", http.StatusConflict)
+ return
+ }
+
+ kp, err := crypto.GenerateSealKeyPair()
+ if err != nil {
+ log.Printf("error generating seal keypair: %v", err)
+ jsonError(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+
+ pubEncoded := crypto.EncodeSealKey(&kp.PublicKey)
+ privEncoded := crypto.EncodeSealKey(&kp.PrivateKey)
+
+ if err := s.acl.SetAgentSealKey(req.AgentName, pubEncoded); err != nil {
+ log.Printf("error storing seal key for %q: %v", req.AgentName, err)
+ jsonError(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+
+ ip := clientIP(r)
+ auditAction := "generate-keypair"
+ if existing != "" {
+ auditAction = "rotate-keypair"
+ }
+ s.logAudit(s.audit.LogAllowed(agentName, auditAction, req.AgentName, ip))
+
+ w.Header().Set("Cache-Control", "no-store")
+ jsonOK(w, map[string]string{
+ "agent_name": req.AgentName,
+ "seal_public_key": pubEncoded,
+ "seal_private_key": privEncoded,
+ })
+}
+
+// handleAgentSubresource routes GET /v1/agents/{name}/seal-key
+func (s *Server) handleAgentSubresource(w http.ResponseWriter, r *http.Request) {
+ // Parse: /v1/agents/{name}/seal-key
+ path := strings.TrimPrefix(r.URL.Path, "/v1/agents/")
+ parts := strings.SplitN(path, "/", 2)
+ if len(parts) != 2 || parts[1] != "seal-key" {
+ jsonError(w, "not found", http.StatusNotFound)
+ return
+ }
+ agentTarget := parts[0]
+ if agentTarget == "" {
+ jsonError(w, "agent name is required", http.StatusBadRequest)
+ return
+ }
+
+ s.handleGetSealKey(w, r, agentTarget)
+}
+
+func (s *Server) handleGetSealKey(w http.ResponseWriter, r *http.Request, agentTarget string) {
+ agentName, err := s.authenticate(r)
+ if err != nil {
+ jsonError(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ if err := s.acl.Authorize(agentName, "keypair", acl.ActionAdmin); err != nil {
+ jsonError(w, "access denied: admin required", http.StatusForbidden)
+ return
+ }
+
+ pubKey, err := s.acl.GetAgentSealKey(agentTarget)
+ if err != nil {
+ jsonError(w, "agent not found", http.StatusNotFound)
+ return
+ }
+
+ jsonOK(w, map[string]string{
+ "agent_name": agentTarget,
+ "seal_public_key": pubKey,
+ })
+}
+
// handleProxy is a stub for the future orchestrator proxy endpoint.
// The orchestrator will accept templated HTTP requests containing phoenix://
// references, resolve them server-side, execute the outbound request, and
diff --git a/internal/api/api_test.go b/internal/api/api_test.go
index a3a086f..87d70d3 100644
--- a/internal/api/api_test.go
+++ b/internal/api/api_test.go
@@ -2909,3 +2909,531 @@ func TestProxyStub(t *testing.T) {
t.Fatalf("proxy should say not yet implemented, got: %s", w.Body.String())
}
}
+
+// --- Sealed Responses Tests (Phase 3) ---
+
+func setupSealedTestServer(t *testing.T) (*Server, string, *crypto.SealKeyPair) {
+ t.Helper()
+ srv, adminToken := setupTestServer(t)
+
+ // Create a test secret
+ body, _ := json.Marshal(setSecretRequest{Value: "sealed-test-value"})
+ req := httptest.NewRequest("PUT", "/v1/secrets/test/sealed-secret", bytes.NewReader(body))
+ req.Header.Set("Authorization", "Bearer "+adminToken)
+ w := httptest.NewRecorder()
+ srv.ServeHTTP(w, req)
+ if w.Code != 200 {
+ t.Fatalf("setup secret: expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ // Generate a seal keypair for the reader agent
+ kp, _ := crypto.GenerateSealKeyPair()
+ pubEncoded := crypto.EncodeSealKey(&kp.PublicKey)
+ srv.acl.SetAgentSealKey("reader", pubEncoded)
+
+ return srv, adminToken, kp
+}
+
+func TestGenerateKeyPairSuccess(t *testing.T) {
+ srv, adminToken := setupTestServer(t)
+
+ body, _ := json.Marshal(generateKeyPairRequest{AgentName: "reader"})
+ req := httptest.NewRequest("POST", "/v1/keypair", bytes.NewReader(body))
+ req.Header.Set("Authorization", "Bearer "+adminToken)
+ w := httptest.NewRecorder()
+ srv.ServeHTTP(w, req)
+
+ if w.Code != 200 {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ var resp map[string]string
+ json.NewDecoder(w.Body).Decode(&resp)
+
+ if resp["agent_name"] != "reader" {
+ t.Errorf("agent_name = %q, want %q", resp["agent_name"], "reader")
+ }
+ if resp["seal_public_key"] == "" {
+ t.Error("seal_public_key is empty")
+ }
+ if resp["seal_private_key"] == "" {
+ t.Error("seal_private_key is empty")
+ }
+ if w.Header().Get("Cache-Control") != "no-store" {
+ t.Errorf("Cache-Control = %q, want %q", w.Header().Get("Cache-Control"), "no-store")
+ }
+
+ // Verify the key was actually stored
+ stored, _ := srv.acl.GetAgentSealKey("reader")
+ if stored != resp["seal_public_key"] {
+ t.Error("stored key doesn't match returned key")
+ }
+}
+
+func TestGenerateKeyPairRotationRequiresForce(t *testing.T) {
+ srv, adminToken := setupTestServer(t)
+
+ // First generation
+ body, _ := json.Marshal(generateKeyPairRequest{AgentName: "reader"})
+ req := httptest.NewRequest("POST", "/v1/keypair", bytes.NewReader(body))
+ req.Header.Set("Authorization", "Bearer "+adminToken)
+ w := httptest.NewRecorder()
+ srv.ServeHTTP(w, req)
+ if w.Code != 200 {
+ t.Fatalf("first gen: expected 200, got %d", w.Code)
+ }
+
+ // Second generation without force should fail
+ body, _ = json.Marshal(generateKeyPairRequest{AgentName: "reader"})
+ req = httptest.NewRequest("POST", "/v1/keypair", bytes.NewReader(body))
+ req.Header.Set("Authorization", "Bearer "+adminToken)
+ w = httptest.NewRecorder()
+ srv.ServeHTTP(w, req)
+ if w.Code != 409 {
+ t.Fatalf("second gen without force: expected 409, got %d: %s", w.Code, w.Body.String())
+ }
+
+ // With force=true should succeed
+ body, _ = json.Marshal(generateKeyPairRequest{AgentName: "reader"})
+ req = httptest.NewRequest("POST", "/v1/keypair?force=true", bytes.NewReader(body))
+ req.Header.Set("Authorization", "Bearer "+adminToken)
+ w = httptest.NewRecorder()
+ srv.ServeHTTP(w, req)
+ if w.Code != 200 {
+ t.Fatalf("gen with force: expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestGenerateKeyPairNonAdmin(t *testing.T) {
+ srv, _ := setupTestServer(t)
+
+ body, _ := json.Marshal(generateKeyPairRequest{AgentName: "reader"})
+ req := httptest.NewRequest("POST", "/v1/keypair", bytes.NewReader(body))
+ req.Header.Set("Authorization", "Bearer reader-token")
+ w := httptest.NewRecorder()
+ srv.ServeHTTP(w, req)
+
+ if w.Code != 403 {
+ t.Fatalf("expected 403, got %d", w.Code)
+ }
+}
+
+func TestGenerateKeyPairAgentNotFound(t *testing.T) {
+ srv, adminToken := setupTestServer(t)
+
+ body, _ := json.Marshal(generateKeyPairRequest{AgentName: "nonexistent"})
+ req := httptest.NewRequest("POST", "/v1/keypair", bytes.NewReader(body))
+ req.Header.Set("Authorization", "Bearer "+adminToken)
+ w := httptest.NewRecorder()
+ srv.ServeHTTP(w, req)
+
+ if w.Code != 404 {
+ t.Fatalf("expected 404, got %d", w.Code)
+ }
+}
+
+func TestGetSealKey(t *testing.T) {
+ srv, adminToken := setupTestServer(t)
+
+ // Generate a keypair first
+ body, _ := json.Marshal(generateKeyPairRequest{AgentName: "reader"})
+ req := httptest.NewRequest("POST", "/v1/keypair", bytes.NewReader(body))
+ req.Header.Set("Authorization", "Bearer "+adminToken)
+ w := httptest.NewRecorder()
+ srv.ServeHTTP(w, req)
+
+ var genResp map[string]string
+ json.NewDecoder(w.Body).Decode(&genResp)
+
+ // Now get the seal key
+ req = httptest.NewRequest("GET", "/v1/agents/reader/seal-key", nil)
+ req.Header.Set("Authorization", "Bearer "+adminToken)
+ w = httptest.NewRecorder()
+ srv.ServeHTTP(w, req)
+
+ if w.Code != 200 {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ var resp map[string]string
+ json.NewDecoder(w.Body).Decode(&resp)
+
+ if resp["agent_name"] != "reader" {
+ t.Errorf("agent_name = %q, want %q", resp["agent_name"], "reader")
+ }
+ if resp["seal_public_key"] != genResp["seal_public_key"] {
+ t.Error("returned public key doesn't match generated key")
+ }
+ // Must never return private key
+ if _, ok := resp["seal_private_key"]; ok {
+ t.Error("seal_private_key must not be returned by GET seal-key")
+ }
+}
+
+func TestSealedGetResponse(t *testing.T) {
+ srv, _, kp := setupSealedTestServer(t)
+
+ req := httptest.NewRequest("GET", "/v1/secrets/test/sealed-secret", nil)
+ req.Header.Set("Authorization", "Bearer reader-token")
+ req.Header.Set("X-Phoenix-Seal-Key", crypto.EncodeSealKey(&kp.PublicKey))
+ w := httptest.NewRecorder()
+ srv.ServeHTTP(w, req)
+
+ if w.Code != 200 {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ var resp map[string]interface{}
+ json.NewDecoder(w.Body).Decode(&resp)
+
+ // Should have sealed_value, not value
+ if _, ok := resp["sealed_value"]; !ok {
+ t.Fatal("expected sealed_value in response")
+ }
+ if _, ok := resp["value"]; ok {
+ t.Fatal("value should not be present in sealed response")
+ }
+ if resp["path"] != "test/sealed-secret" {
+ t.Errorf("path = %v, want %q", resp["path"], "test/sealed-secret")
+ }
+ if w.Header().Get("Cache-Control") != "no-store" {
+ t.Errorf("Cache-Control = %q, want %q", w.Header().Get("Cache-Control"), "no-store")
+ }
+
+ // Verify we can decrypt the sealed value
+ envJSON, _ := json.Marshal(resp["sealed_value"])
+ var env crypto.SealedEnvelope
+ json.Unmarshal(envJSON, &env)
+
+ payload, err := crypto.OpenSealedEnvelope(&env, &kp.PrivateKey)
+ if err != nil {
+ t.Fatalf("OpenSealedEnvelope: %v", err)
+ }
+ if payload.Value != "sealed-test-value" {
+ t.Errorf("decrypted value = %q, want %q", payload.Value, "sealed-test-value")
+ }
+}
+
+func TestSealedResolveResponse(t *testing.T) {
+ srv, _, kp := setupSealedTestServer(t)
+
+ body, _ := json.Marshal(resolveRequest{
+ Refs: []string{"phoenix://test/sealed-secret"},
+ })
+ req := httptest.NewRequest("POST", "/v1/resolve", bytes.NewReader(body))
+ req.Header.Set("Authorization", "Bearer reader-token")
+ req.Header.Set("X-Phoenix-Seal-Key", crypto.EncodeSealKey(&kp.PublicKey))
+ w := httptest.NewRecorder()
+ srv.ServeHTTP(w, req)
+
+ if w.Code != 200 {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ var resp map[string]interface{}
+ json.NewDecoder(w.Body).Decode(&resp)
+
+ // Should have sealed_values, not values
+ if _, ok := resp["sealed_values"]; !ok {
+ t.Fatal("expected sealed_values in response")
+ }
+ if _, ok := resp["values"]; ok {
+ t.Fatal("values should not be present in sealed response")
+ }
+ if w.Header().Get("Cache-Control") != "no-store" {
+ t.Errorf("Cache-Control = %q, want %q", w.Header().Get("Cache-Control"), "no-store")
+ }
+
+ // Verify we can decrypt
+ sealedMap := resp["sealed_values"].(map[string]interface{})
+ envJSON, _ := json.Marshal(sealedMap["phoenix://test/sealed-secret"])
+ var env crypto.SealedEnvelope
+ json.Unmarshal(envJSON, &env)
+
+ payload, err := crypto.OpenSealedEnvelope(&env, &kp.PrivateKey)
+ if err != nil {
+ t.Fatalf("OpenSealedEnvelope: %v", err)
+ }
+ if payload.Value != "sealed-test-value" {
+ t.Errorf("decrypted value = %q, want %q", payload.Value, "sealed-test-value")
+ }
+ if payload.Ref != "phoenix://test/sealed-secret" {
+ t.Errorf("ref = %q, want %q", payload.Ref, "phoenix://test/sealed-secret")
+ }
+}
+
+func TestPlaintextFallbackWithoutSealHeader(t *testing.T) {
+ srv, _, _ := setupSealedTestServer(t)
+
+ // GET without seal header should return plaintext
+ req := httptest.NewRequest("GET", "/v1/secrets/test/sealed-secret", nil)
+ req.Header.Set("Authorization", "Bearer reader-token")
+ w := httptest.NewRecorder()
+ srv.ServeHTTP(w, req)
+
+ if w.Code != 200 {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ var resp map[string]interface{}
+ json.NewDecoder(w.Body).Decode(&resp)
+
+ if _, ok := resp["sealed_value"]; ok {
+ t.Fatal("sealed_value should not be present without seal header")
+ }
+ if resp["value"] != "sealed-test-value" {
+ t.Errorf("value = %v, want %q", resp["value"], "sealed-test-value")
+ }
+}
+
+func TestMalformedSealHeaderRejected(t *testing.T) {
+ srv, _, _ := setupSealedTestServer(t)
+
+ req := httptest.NewRequest("GET", "/v1/secrets/test/sealed-secret", nil)
+ req.Header.Set("Authorization", "Bearer reader-token")
+ req.Header.Set("X-Phoenix-Seal-Key", "not-valid-base64!!!")
+ w := httptest.NewRecorder()
+ srv.ServeHTTP(w, req)
+
+ if w.Code != 400 {
+ t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestMismatchedSealKeyRejected(t *testing.T) {
+ srv, _, _ := setupSealedTestServer(t)
+
+ // Generate a different key pair
+ otherKP, _ := crypto.GenerateSealKeyPair()
+
+ req := httptest.NewRequest("GET", "/v1/secrets/test/sealed-secret", nil)
+ req.Header.Set("Authorization", "Bearer reader-token")
+ req.Header.Set("X-Phoenix-Seal-Key", crypto.EncodeSealKey(&otherKP.PublicKey))
+ w := httptest.NewRecorder()
+ srv.ServeHTTP(w, req)
+
+ if w.Code != 400 {
+ t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
+ }
+ if !strings.Contains(w.Body.String(), "does not match") {
+ t.Errorf("expected 'does not match' error, got: %s", w.Body.String())
+ }
+}
+
+func TestUnregisteredAgentSealKeyRejected(t *testing.T) {
+ srv, adminToken := setupTestServer(t)
+
+ // Set a secret that admin can read
+ body, _ := json.Marshal(setSecretRequest{Value: "test-val"})
+ req := httptest.NewRequest("PUT", "/v1/secrets/test/s", bytes.NewReader(body))
+ req.Header.Set("Authorization", "Bearer "+adminToken)
+ w := httptest.NewRecorder()
+ srv.ServeHTTP(w, req)
+
+ // reader has no seal key registered — presenting one should fail
+ kp, _ := crypto.GenerateSealKeyPair()
+ req = httptest.NewRequest("GET", "/v1/secrets/test/s", nil)
+ req.Header.Set("Authorization", "Bearer reader-token")
+ req.Header.Set("X-Phoenix-Seal-Key", crypto.EncodeSealKey(&kp.PublicKey))
+ w = httptest.NewRecorder()
+ srv.ServeHTTP(w, req)
+
+ if w.Code != 400 {
+ t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
+ }
+ if !strings.Contains(w.Body.String(), "no registered seal key") {
+ t.Errorf("expected 'no registered seal key' error, got: %s", w.Body.String())
+ }
+}
+
+func TestRequireSealedPolicyDeniesWithoutKey(t *testing.T) {
+ srv, adminToken := setupTestServer(t)
+
+ // Set a secret
+ body, _ := json.Marshal(setSecretRequest{Value: "policy-test"})
+ req := httptest.NewRequest("PUT", "/v1/secrets/test/sealed-pol", bytes.NewReader(body))
+ req.Header.Set("Authorization", "Bearer "+adminToken)
+ w := httptest.NewRecorder()
+ srv.ServeHTTP(w, req)
+
+ // Set require_sealed policy
+ policyJSON := `{"attestation":{"test/*":{"require_sealed":true}}}`
+ e, _ := policy.Load([]byte(policyJSON))
+ srv.SetPolicy(e)
+
+ // Reader without seal key should be denied
+ req = httptest.NewRequest("GET", "/v1/secrets/test/sealed-pol", nil)
+ req.Header.Set("Authorization", "Bearer reader-token")
+ w = httptest.NewRecorder()
+ srv.ServeHTTP(w, req)
+
+ if w.Code != 403 {
+ t.Fatalf("expected 403, got %d: %s", w.Code, w.Body.String())
+ }
+ if !strings.Contains(w.Body.String(), "sealed response required") {
+ t.Errorf("expected 'sealed response required' in error, got: %s", w.Body.String())
+ }
+}
+
+func TestRequireSealedPolicyAllowsWithKey(t *testing.T) {
+ srv, adminToken := setupTestServer(t)
+
+ body, _ := json.Marshal(setSecretRequest{Value: "policy-test"})
+ req := httptest.NewRequest("PUT", "/v1/secrets/test/sealed-pol", bytes.NewReader(body))
+ req.Header.Set("Authorization", "Bearer "+adminToken)
+ w := httptest.NewRecorder()
+ srv.ServeHTTP(w, req)
+
+ // Register seal key for reader
+ kp, _ := crypto.GenerateSealKeyPair()
+ srv.acl.SetAgentSealKey("reader", crypto.EncodeSealKey(&kp.PublicKey))
+
+ // Set require_sealed policy
+ policyJSON := `{"attestation":{"test/*":{"require_sealed":true}}}`
+ e, _ := policy.Load([]byte(policyJSON))
+ srv.SetPolicy(e)
+
+ // Reader with valid seal key should be allowed
+ req = httptest.NewRequest("GET", "/v1/secrets/test/sealed-pol", nil)
+ req.Header.Set("Authorization", "Bearer reader-token")
+ req.Header.Set("X-Phoenix-Seal-Key", crypto.EncodeSealKey(&kp.PublicKey))
+ w = httptest.NewRecorder()
+ srv.ServeHTTP(w, req)
+
+ if w.Code != 200 {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestDryRunUnchangedWithSealKey(t *testing.T) {
+ srv, _, kp := setupSealedTestServer(t)
+
+ body, _ := json.Marshal(resolveRequest{
+ Refs: []string{"phoenix://test/sealed-secret"},
+ })
+ req := httptest.NewRequest("POST", "/v1/resolve?dry_run=true", bytes.NewReader(body))
+ req.Header.Set("Authorization", "Bearer reader-token")
+ req.Header.Set("X-Phoenix-Seal-Key", crypto.EncodeSealKey(&kp.PublicKey))
+ w := httptest.NewRecorder()
+ srv.ServeHTTP(w, req)
+
+ if w.Code != 200 {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ var resp map[string]interface{}
+ json.NewDecoder(w.Body).Decode(&resp)
+
+ // dry_run should still return values map with "ok", not sealed_values
+ vals, ok := resp["values"].(map[string]interface{})
+ if !ok {
+ t.Fatal("expected values map in dry_run response")
+ }
+ if vals["phoenix://test/sealed-secret"] != "ok" {
+ t.Errorf("dry_run value = %v, want %q", vals["phoenix://test/sealed-secret"], "ok")
+ }
+ if _, ok := resp["sealed_values"]; ok {
+ t.Fatal("sealed_values should not be present in dry_run response")
+ }
+}
+
+// --- Policy check endpoint tests ---
+
+func TestPolicyCheckAllowUnseal(t *testing.T) {
+ srv, adminToken := setupTestServer(t)
+
+ pe, _ := policy.Load([]byte(`{"rules":[{"path":"test/*","allow_unseal":true}]}`))
+ srv.SetPolicy(pe)
+
+ req := httptest.NewRequest("GET", "/v1/policy/check?path=test/secret&check=allow_unseal", nil)
+ req.Header.Set("Authorization", "Bearer "+adminToken)
+ w := httptest.NewRecorder()
+ srv.ServeHTTP(w, req)
+
+ if w.Code != 200 {
+ t.Fatalf("status = %d, want 200", w.Code)
+ }
+ var resp map[string]interface{}
+ json.NewDecoder(w.Body).Decode(&resp)
+ if resp["allowed"] != true {
+ t.Fatalf("allowed = %v, want true", resp["allowed"])
+ }
+}
+
+func TestPolicyCheckDenyUnseal(t *testing.T) {
+ srv, adminToken := setupTestServer(t)
+
+ pe, _ := policy.Load([]byte(`{"rules":[{"path":"other/*","allow_unseal":true}]}`))
+ srv.SetPolicy(pe)
+
+ req := httptest.NewRequest("GET", "/v1/policy/check?path=test/secret&check=allow_unseal", nil)
+ req.Header.Set("Authorization", "Bearer "+adminToken)
+ w := httptest.NewRecorder()
+ srv.ServeHTTP(w, req)
+
+ if w.Code != 200 {
+ t.Fatalf("status = %d, want 200", w.Code)
+ }
+ var resp map[string]interface{}
+ json.NewDecoder(w.Body).Decode(&resp)
+ if resp["allowed"] != false {
+ t.Fatalf("allowed = %v, want false", resp["allowed"])
+ }
+}
+
+func TestPolicyCheckNoPolicy(t *testing.T) {
+ srv, adminToken := setupTestServer(t)
+ // No policy set — should return allowed=false
+
+ req := httptest.NewRequest("GET", "/v1/policy/check?path=test/secret&check=allow_unseal", nil)
+ req.Header.Set("Authorization", "Bearer "+adminToken)
+ w := httptest.NewRecorder()
+ srv.ServeHTTP(w, req)
+
+ if w.Code != 200 {
+ t.Fatalf("status = %d, want 200", w.Code)
+ }
+ var resp map[string]interface{}
+ json.NewDecoder(w.Body).Decode(&resp)
+ if resp["allowed"] != false {
+ t.Fatalf("allowed = %v, want false (no policy)", resp["allowed"])
+ }
+}
+
+func TestPolicyCheckRequiresAuth(t *testing.T) {
+ srv, _ := setupTestServer(t)
+
+ req := httptest.NewRequest("GET", "/v1/policy/check?path=test/secret&check=allow_unseal", nil)
+ w := httptest.NewRecorder()
+ srv.ServeHTTP(w, req)
+
+ if w.Code != 401 {
+ t.Fatalf("status = %d, want 401", w.Code)
+ }
+}
+
+func TestPolicyCheckMissingParams(t *testing.T) {
+ srv, adminToken := setupTestServer(t)
+
+ req := httptest.NewRequest("GET", "/v1/policy/check?path=test/secret", nil)
+ req.Header.Set("Authorization", "Bearer "+adminToken)
+ w := httptest.NewRecorder()
+ srv.ServeHTTP(w, req)
+
+ if w.Code != 400 {
+ t.Fatalf("status = %d, want 400", w.Code)
+ }
+}
+
+func TestPolicyCheckUnsupportedCheck(t *testing.T) {
+ srv, adminToken := setupTestServer(t)
+
+ req := httptest.NewRequest("GET", "/v1/policy/check?path=test/secret&check=bogus", nil)
+ req.Header.Set("Authorization", "Bearer "+adminToken)
+ w := httptest.NewRecorder()
+ srv.ServeHTTP(w, req)
+
+ if w.Code != 400 {
+ t.Fatalf("status = %d, want 400", w.Code)
+ }
+}
diff --git a/internal/crypto/seal.go b/internal/crypto/seal.go
new file mode 100644
index 0000000..4f68243
--- /dev/null
+++ b/internal/crypto/seal.go
@@ -0,0 +1,228 @@
+package crypto
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "time"
+
+ "golang.org/x/crypto/curve25519"
+ "golang.org/x/crypto/nacl/box"
+)
+
+const (
+ SealKeySize = 32
+ SealNonceSize = 24
+ SealAlgorithm = "x25519-xsalsa20-poly1305"
+ SealVersion = 1
+)
+
+var (
+ ErrSealKeySize = errors.New("seal key must be exactly 32 bytes")
+ ErrSealDecryptFailed = errors.New("sealed envelope decryption failed")
+ ErrSealPathMismatch = errors.New("inner/outer path mismatch: envelope may be tampered")
+ ErrSealRefMismatch = errors.New("inner/outer ref mismatch: envelope may be tampered")
+ ErrSealKeyFileNotFound = errors.New("seal key file not found")
+ ErrSealKeyInsecurePerm = errors.New("seal key file has insecure permissions: must not be readable by group or others")
+ ErrSealVersionUnsup = errors.New("unsupported sealed envelope version")
+ ErrSealAlgorithmUnsup = errors.New("unsupported sealed envelope algorithm")
+)
+
+// SealKeyPair holds an X25519 key pair for sealed responses.
+type SealKeyPair struct {
+ PublicKey [32]byte
+ PrivateKey [32]byte
+}
+
+// SealedEnvelope is the outer wire format for a sealed secret value.
+type SealedEnvelope struct {
+ Version int `json:"version"`
+ Algorithm string `json:"algorithm"`
+ Path string `json:"path"`
+ Ref string `json:"ref,omitempty"`
+ EphemeralKey string `json:"ephemeral_key"`
+ Nonce string `json:"nonce"`
+ Ciphertext string `json:"ciphertext"`
+}
+
+// SealedPayload is the inner encrypted payload bound inside the ciphertext.
+type SealedPayload struct {
+ Path string `json:"path"`
+ Ref string `json:"ref,omitempty"`
+ Value string `json:"value"`
+ IssuedAt string `json:"issued_at"`
+}
+
+// GenerateSealKeyPair creates a new X25519 key pair for sealed responses.
+func GenerateSealKeyPair() (*SealKeyPair, error) {
+ pub, priv, err := box.GenerateKey(rand.Reader)
+ if err != nil {
+ return nil, fmt.Errorf("generating seal key pair: %w", err)
+ }
+ return &SealKeyPair{
+ PublicKey: *pub,
+ PrivateKey: *priv,
+ }, nil
+}
+
+// SealValue encrypts a secret value for a specific recipient using NaCl box.
+// It creates an ephemeral X25519 key pair, encrypts the payload, and discards
+// the ephemeral private key.
+func SealValue(path, ref, value string, recipientPubKey *[32]byte) (*SealedEnvelope, error) {
+ payload := SealedPayload{
+ Path: path,
+ Ref: ref,
+ Value: value,
+ IssuedAt: time.Now().UTC().Format(time.RFC3339),
+ }
+
+ plaintext, err := json.Marshal(payload)
+ if err != nil {
+ return nil, fmt.Errorf("marshaling sealed payload: %w", err)
+ }
+
+ ephPub, ephPriv, err := box.GenerateKey(rand.Reader)
+ if err != nil {
+ return nil, fmt.Errorf("generating ephemeral key: %w", err)
+ }
+ defer func() {
+ ZeroBytes(ephPriv[:])
+ }()
+
+ var nonce [SealNonceSize]byte
+ if _, err := io.ReadFull(rand.Reader, nonce[:]); err != nil {
+ return nil, fmt.Errorf("generating nonce: %w", err)
+ }
+
+ ciphertext := box.Seal(nil, plaintext, &nonce, recipientPubKey, ephPriv)
+
+ return &SealedEnvelope{
+ Version: SealVersion,
+ Algorithm: SealAlgorithm,
+ Path: path,
+ Ref: ref,
+ EphemeralKey: base64.StdEncoding.EncodeToString(ephPub[:]),
+ Nonce: base64.StdEncoding.EncodeToString(nonce[:]),
+ Ciphertext: base64.StdEncoding.EncodeToString(ciphertext),
+ }, nil
+}
+
+// OpenSealedEnvelope decrypts a sealed envelope using the recipient's private key.
+// After decryption, it verifies that the inner path/ref match the outer envelope
+// to prevent relabeling attacks.
+func OpenSealedEnvelope(env *SealedEnvelope, recipientPrivKey *[32]byte) (*SealedPayload, error) {
+ if env.Version != SealVersion {
+ return nil, fmt.Errorf("%w: got %d, want %d", ErrSealVersionUnsup, env.Version, SealVersion)
+ }
+ if env.Algorithm != SealAlgorithm {
+ return nil, fmt.Errorf("%w: got %q", ErrSealAlgorithmUnsup, env.Algorithm)
+ }
+
+ ephKeyBytes, err := base64.StdEncoding.DecodeString(env.EphemeralKey)
+ if err != nil {
+ return nil, fmt.Errorf("decoding ephemeral key: %w", err)
+ }
+ if len(ephKeyBytes) != SealKeySize {
+ return nil, fmt.Errorf("ephemeral key wrong size: got %d, want %d", len(ephKeyBytes), SealKeySize)
+ }
+
+ nonceBytes, err := base64.StdEncoding.DecodeString(env.Nonce)
+ if err != nil {
+ return nil, fmt.Errorf("decoding nonce: %w", err)
+ }
+ if len(nonceBytes) != SealNonceSize {
+ return nil, fmt.Errorf("nonce wrong size: got %d, want %d", len(nonceBytes), SealNonceSize)
+ }
+
+ ciphertext, err := base64.StdEncoding.DecodeString(env.Ciphertext)
+ if err != nil {
+ return nil, fmt.Errorf("decoding ciphertext: %w", err)
+ }
+
+ var ephPub [32]byte
+ copy(ephPub[:], ephKeyBytes)
+
+ var nonce [24]byte
+ copy(nonce[:], nonceBytes)
+
+ plaintext, ok := box.Open(nil, ciphertext, &nonce, &ephPub, recipientPrivKey)
+ if !ok {
+ return nil, ErrSealDecryptFailed
+ }
+
+ var payload SealedPayload
+ if err := json.Unmarshal(plaintext, &payload); err != nil {
+ return nil, fmt.Errorf("unmarshaling sealed payload: %w", err)
+ }
+
+ if payload.Path != env.Path {
+ return nil, ErrSealPathMismatch
+ }
+ if payload.Ref != env.Ref {
+ return nil, ErrSealRefMismatch
+ }
+
+ return &payload, nil
+}
+
+// LoadSealPrivateKey reads a base64-encoded 32-byte X25519 private key from a file.
+func LoadSealPrivateKey(path string) (*[32]byte, error) {
+ f, err := os.Open(path)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, ErrSealKeyFileNotFound
+ }
+ if err != nil {
+ return nil, fmt.Errorf("opening seal key file: %w", err)
+ }
+ defer f.Close()
+
+ info, err := f.Stat()
+ if err != nil {
+ return nil, fmt.Errorf("stat seal key file: %w", err)
+ }
+ if info.Mode().Perm()&0o077 != 0 {
+ return nil, fmt.Errorf("%w: %s has mode %04o", ErrSealKeyInsecurePerm, path, info.Mode().Perm())
+ }
+
+ data, err := io.ReadAll(f)
+ if err != nil {
+ return nil, fmt.Errorf("reading seal key file: %w", err)
+ }
+
+ key, err := DecodeSealKey(string(data))
+ if err != nil {
+ return nil, fmt.Errorf("decoding seal key from %s: %w", path, err)
+ }
+ return key, nil
+}
+
+// DeriveSealPublicKey derives the X25519 public key from a private key.
+func DeriveSealPublicKey(privKey *[32]byte) *[32]byte {
+ pub, _ := curve25519.X25519(privKey[:], curve25519.Basepoint)
+ var out [32]byte
+ copy(out[:], pub)
+ return &out
+}
+
+// EncodeSealKey encodes a 32-byte key as base64.
+func EncodeSealKey(key *[32]byte) string {
+ return base64.StdEncoding.EncodeToString(key[:])
+}
+
+// DecodeSealKey decodes a base64-encoded 32-byte key.
+func DecodeSealKey(encoded string) (*[32]byte, error) {
+ raw, err := base64.StdEncoding.DecodeString(encoded)
+ if err != nil {
+ return nil, fmt.Errorf("base64 decode: %w", err)
+ }
+ if len(raw) != SealKeySize {
+ return nil, ErrSealKeySize
+ }
+ var key [32]byte
+ copy(key[:], raw)
+ return &key, nil
+}
diff --git a/internal/crypto/seal_test.go b/internal/crypto/seal_test.go
new file mode 100644
index 0000000..0192d67
--- /dev/null
+++ b/internal/crypto/seal_test.go
@@ -0,0 +1,346 @@
+package crypto
+
+import (
+ "encoding/base64"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestGenerateSealKeyPair(t *testing.T) {
+ kp1, err := GenerateSealKeyPair()
+ if err != nil {
+ t.Fatalf("GenerateSealKeyPair: %v", err)
+ }
+
+ kp2, err := GenerateSealKeyPair()
+ if err != nil {
+ t.Fatalf("GenerateSealKeyPair second: %v", err)
+ }
+
+ if kp1.PublicKey == kp2.PublicKey {
+ t.Error("two generated key pairs have identical public keys")
+ }
+ if kp1.PrivateKey == kp2.PrivateKey {
+ t.Error("two generated key pairs have identical private keys")
+ }
+}
+
+func TestSealRoundTrip(t *testing.T) {
+ kp, err := GenerateSealKeyPair()
+ if err != nil {
+ t.Fatalf("GenerateSealKeyPair: %v", err)
+ }
+
+ path := "myapp/api-key"
+ ref := "phoenix://myapp/api-key"
+ value := "super-secret-value-12345"
+
+ env, err := SealValue(path, ref, value, &kp.PublicKey)
+ if err != nil {
+ t.Fatalf("SealValue: %v", err)
+ }
+
+ if env.Version != SealVersion {
+ t.Errorf("version = %d, want %d", env.Version, SealVersion)
+ }
+ if env.Algorithm != SealAlgorithm {
+ t.Errorf("algorithm = %q, want %q", env.Algorithm, SealAlgorithm)
+ }
+ if env.Path != path {
+ t.Errorf("path = %q, want %q", env.Path, path)
+ }
+ if env.Ref != ref {
+ t.Errorf("ref = %q, want %q", env.Ref, ref)
+ }
+
+ payload, err := OpenSealedEnvelope(env, &kp.PrivateKey)
+ if err != nil {
+ t.Fatalf("OpenSealedEnvelope: %v", err)
+ }
+
+ if payload.Value != value {
+ t.Errorf("value = %q, want %q", payload.Value, value)
+ }
+ if payload.Path != path {
+ t.Errorf("path = %q, want %q", payload.Path, path)
+ }
+ if payload.Ref != ref {
+ t.Errorf("ref = %q, want %q", payload.Ref, ref)
+ }
+ if payload.IssuedAt == "" {
+ t.Error("issued_at is empty")
+ }
+}
+
+func TestSealRoundTripNoRef(t *testing.T) {
+ kp, err := GenerateSealKeyPair()
+ if err != nil {
+ t.Fatalf("GenerateSealKeyPair: %v", err)
+ }
+
+ env, err := SealValue("db/password", "", "secret", &kp.PublicKey)
+ if err != nil {
+ t.Fatalf("SealValue: %v", err)
+ }
+
+ payload, err := OpenSealedEnvelope(env, &kp.PrivateKey)
+ if err != nil {
+ t.Fatalf("OpenSealedEnvelope: %v", err)
+ }
+
+ if payload.Value != "secret" {
+ t.Errorf("value = %q, want %q", payload.Value, "secret")
+ }
+}
+
+func TestSealWrongKeyRejected(t *testing.T) {
+ kp1, _ := GenerateSealKeyPair()
+ kp2, _ := GenerateSealKeyPair()
+
+ env, err := SealValue("test/secret", "", "value", &kp1.PublicKey)
+ if err != nil {
+ t.Fatalf("SealValue: %v", err)
+ }
+
+ _, err = OpenSealedEnvelope(env, &kp2.PrivateKey)
+ if err != ErrSealDecryptFailed {
+ t.Errorf("expected ErrSealDecryptFailed, got %v", err)
+ }
+}
+
+func TestSealTamperedCiphertextRejected(t *testing.T) {
+ kp, _ := GenerateSealKeyPair()
+
+ env, _ := SealValue("test/secret", "", "value", &kp.PublicKey)
+
+ raw, _ := base64.StdEncoding.DecodeString(env.Ciphertext)
+ raw[0] ^= 0xff
+ env.Ciphertext = base64.StdEncoding.EncodeToString(raw)
+
+ _, err := OpenSealedEnvelope(env, &kp.PrivateKey)
+ if err != ErrSealDecryptFailed {
+ t.Errorf("expected ErrSealDecryptFailed, got %v", err)
+ }
+}
+
+func TestSealMalformedBase64Rejected(t *testing.T) {
+ kp, _ := GenerateSealKeyPair()
+
+ tests := []struct {
+ name string
+ mutate func(env *SealedEnvelope)
+ }{
+ {"bad ephemeral_key", func(env *SealedEnvelope) { env.EphemeralKey = "not-base64!!!" }},
+ {"bad nonce", func(env *SealedEnvelope) { env.Nonce = "not-base64!!!" }},
+ {"bad ciphertext", func(env *SealedEnvelope) { env.Ciphertext = "not-base64!!!" }},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ env, _ := SealValue("test/secret", "", "value", &kp.PublicKey)
+ tt.mutate(env)
+ _, err := OpenSealedEnvelope(env, &kp.PrivateKey)
+ if err == nil {
+ t.Error("expected error for malformed base64")
+ }
+ })
+ }
+}
+
+func TestSealPathMismatchRejected(t *testing.T) {
+ kp, _ := GenerateSealKeyPair()
+
+ env, _ := SealValue("real/path", "", "value", &kp.PublicKey)
+ env.Path = "fake/path"
+
+ _, err := OpenSealedEnvelope(env, &kp.PrivateKey)
+ if err != ErrSealPathMismatch {
+ t.Errorf("expected ErrSealPathMismatch, got %v", err)
+ }
+}
+
+func TestSealRefMismatchRejected(t *testing.T) {
+ kp, _ := GenerateSealKeyPair()
+
+ env, _ := SealValue("test/path", "phoenix://test/path", "value", &kp.PublicKey)
+ env.Ref = "phoenix://other/path"
+
+ _, err := OpenSealedEnvelope(env, &kp.PrivateKey)
+ if err != ErrSealRefMismatch {
+ t.Errorf("expected ErrSealRefMismatch, got %v", err)
+ }
+}
+
+func TestSealRefStrippedFromOuterRejected(t *testing.T) {
+ kp, _ := GenerateSealKeyPair()
+
+ // Seal with a ref set
+ env, _ := SealValue("test/path", "phoenix://test/path", "value", &kp.PublicKey)
+
+ // Attacker strips outer ref to bypass mismatch check
+ env.Ref = ""
+
+ _, err := OpenSealedEnvelope(env, &kp.PrivateKey)
+ if err != ErrSealRefMismatch {
+ t.Errorf("expected ErrSealRefMismatch when outer ref stripped, got %v", err)
+ }
+}
+
+func TestSealKeyEncodeDecode(t *testing.T) {
+ kp, _ := GenerateSealKeyPair()
+
+ encoded := EncodeSealKey(&kp.PublicKey)
+ decoded, err := DecodeSealKey(encoded)
+ if err != nil {
+ t.Fatalf("DecodeSealKey: %v", err)
+ }
+ if *decoded != kp.PublicKey {
+ t.Error("round-trip encode/decode changed key value")
+ }
+}
+
+func TestDecodeSealKeyBadBase64(t *testing.T) {
+ _, err := DecodeSealKey("not-valid-base64!!!")
+ if err == nil {
+ t.Error("expected error for bad base64")
+ }
+}
+
+func TestDecodeSealKeyWrongSize(t *testing.T) {
+ short := base64.StdEncoding.EncodeToString([]byte("tooshort"))
+ _, err := DecodeSealKey(short)
+ if err != ErrSealKeySize {
+ t.Errorf("expected ErrSealKeySize, got %v", err)
+ }
+}
+
+func TestDeriveSealPublicKey(t *testing.T) {
+ kp, _ := GenerateSealKeyPair()
+
+ derived := DeriveSealPublicKey(&kp.PrivateKey)
+ if *derived != kp.PublicKey {
+ t.Error("derived public key does not match generated public key")
+ }
+}
+
+func TestLoadSealPrivateKey(t *testing.T) {
+ kp, _ := GenerateSealKeyPair()
+
+ dir := t.TempDir()
+ keyFile := filepath.Join(dir, "test.seal.key")
+
+ encoded := EncodeSealKey(&kp.PrivateKey)
+ if err := os.WriteFile(keyFile, []byte(encoded), 0600); err != nil {
+ t.Fatalf("writing key file: %v", err)
+ }
+
+ loaded, err := LoadSealPrivateKey(keyFile)
+ if err != nil {
+ t.Fatalf("LoadSealPrivateKey: %v", err)
+ }
+ if *loaded != kp.PrivateKey {
+ t.Error("loaded key does not match original")
+ }
+}
+
+func TestLoadSealPrivateKeyNotFound(t *testing.T) {
+ _, err := LoadSealPrivateKey("/nonexistent/path/key.seal")
+ if err != ErrSealKeyFileNotFound {
+ t.Errorf("expected ErrSealKeyFileNotFound, got %v", err)
+ }
+}
+
+func TestLoadSealPrivateKeyBadContent(t *testing.T) {
+ dir := t.TempDir()
+ keyFile := filepath.Join(dir, "bad.seal.key")
+
+ if err := os.WriteFile(keyFile, []byte("not-a-valid-key"), 0600); err != nil {
+ t.Fatalf("writing key file: %v", err)
+ }
+
+ _, err := LoadSealPrivateKey(keyFile)
+ if err == nil {
+ t.Error("expected error for bad key content")
+ }
+}
+
+func TestOpenSealedEnvelopeRejectsWrongVersion(t *testing.T) {
+ kp, _ := GenerateSealKeyPair()
+ env, _ := SealValue("test/path", "", "value", &kp.PublicKey)
+
+ env.Version = 99
+
+ _, err := OpenSealedEnvelope(env, &kp.PrivateKey)
+ if !errors.Is(err, ErrSealVersionUnsup) {
+ t.Errorf("expected ErrSealVersionUnsup, got %v", err)
+ }
+}
+
+func TestOpenSealedEnvelopeRejectsWrongAlgorithm(t *testing.T) {
+ kp, _ := GenerateSealKeyPair()
+ env, _ := SealValue("test/path", "", "value", &kp.PublicKey)
+
+ env.Algorithm = "aes-256-gcm"
+
+ _, err := OpenSealedEnvelope(env, &kp.PrivateKey)
+ if !errors.Is(err, ErrSealAlgorithmUnsup) {
+ t.Errorf("expected ErrSealAlgorithmUnsup, got %v", err)
+ }
+}
+
+func TestLoadSealPrivateKeyInsecurePerms(t *testing.T) {
+ kp, _ := GenerateSealKeyPair()
+ dir := t.TempDir()
+ keyFile := filepath.Join(dir, "insecure.seal.key")
+
+ encoded := EncodeSealKey(&kp.PrivateKey)
+
+ // Group-readable (0640)
+ if err := os.WriteFile(keyFile, []byte(encoded), 0640); err != nil {
+ t.Fatalf("writing key file: %v", err)
+ }
+ _, err := LoadSealPrivateKey(keyFile)
+ if !errors.Is(err, ErrSealKeyInsecurePerm) {
+ t.Errorf("expected ErrSealKeyInsecurePerm for 0640, got %v", err)
+ }
+
+ // World-readable (0644)
+ os.Chmod(keyFile, 0644)
+ _, err = LoadSealPrivateKey(keyFile)
+ if !errors.Is(err, ErrSealKeyInsecurePerm) {
+ t.Errorf("expected ErrSealKeyInsecurePerm for 0644, got %v", err)
+ }
+
+ // Owner-only (0600) should succeed
+ os.Chmod(keyFile, 0600)
+ _, err = LoadSealPrivateKey(keyFile)
+ if err != nil {
+ t.Errorf("expected success for 0600, got %v", err)
+ }
+}
+
+func TestSealEphemeralKeyWrongSize(t *testing.T) {
+ kp, _ := GenerateSealKeyPair()
+ env, _ := SealValue("test/path", "", "value", &kp.PublicKey)
+
+ env.EphemeralKey = base64.StdEncoding.EncodeToString([]byte("short"))
+
+ _, err := OpenSealedEnvelope(env, &kp.PrivateKey)
+ if err == nil {
+ t.Error("expected error for wrong-size ephemeral key")
+ }
+}
+
+func TestSealNonceWrongSize(t *testing.T) {
+ kp, _ := GenerateSealKeyPair()
+ env, _ := SealValue("test/path", "", "value", &kp.PublicKey)
+
+ env.Nonce = base64.StdEncoding.EncodeToString([]byte("short"))
+
+ _, err := OpenSealedEnvelope(env, &kp.PrivateKey)
+ if err == nil {
+ t.Error("expected error for wrong-size nonce")
+ }
+}
diff --git a/internal/policy/policy.go b/internal/policy/policy.go
index 8d83c2a..d289a2e 100644
--- a/internal/policy/policy.go
+++ b/internal/policy/policy.go
@@ -82,6 +82,10 @@ type Rule struct {
// When true, require_nonce resolves must include a detached signature
// over the canonical payload, verified against the mTLS cert public key.
RequireSigned bool `json:"require_signed,omitempty"`
+
+ // L9: sealed response requirements
+ RequireSealed bool `json:"require_sealed,omitempty"` // deny if no valid seal key presented
+ AllowUnseal bool `json:"allow_unseal,omitempty"` // gate for MCP unseal tool
}
// ProcessContext contains process-level attestation evidence.
@@ -113,6 +117,9 @@ type RequestContext struct {
// Override evaluation time (for testing; zero means use time.Now())
EvalTime time.Time
+
+ // Seal key validation (set when X-Phoenix-Seal-Key is present and validated)
+ SealKeyPresented bool
}
// Engine loads and evaluates attestation policies.
@@ -151,6 +158,8 @@ type legacyRule struct {
RequireNonce bool `json:"require_nonce"`
NonceMaxAge string `json:"nonce_max_age,omitempty"`
RequireSigned bool `json:"require_signed,omitempty"`
+ RequireSealed bool `json:"require_sealed,omitempty"`
+ AllowUnseal bool `json:"allow_unseal,omitempty"`
}
// NewEngine creates a policy engine with no rules (all requests pass).
@@ -225,6 +234,8 @@ func Load(data []byte) (*Engine, error) {
RequireNonce: r.RequireNonce,
NonceMaxAge: r.NonceMaxAge,
RequireSigned: r.RequireSigned,
+ RequireSealed: r.RequireSealed,
+ AllowUnseal: r.AllowUnseal,
}
}
return &Engine{rules: rules}, nil
@@ -432,6 +443,14 @@ func (e *Engine) Evaluate(secretPath string, ctx *RequestContext) error {
}
}
+ // L9: Check sealed response requirement
+ if rule.RequireSealed && !ctx.SealKeyPresented {
+ return &DeniedError{
+ Pattern: pattern,
+ Reason: "sealed response required but no valid seal key presented",
+ }
+ }
+
return nil
}
diff --git a/internal/policy/policy_test.go b/internal/policy/policy_test.go
index 3c95496..61cd52d 100644
--- a/internal/policy/policy_test.go
+++ b/internal/policy/policy_test.go
@@ -1004,3 +1004,151 @@ func TestRequireSignedNotNeededWithoutPolicy(t *testing.T) {
t.Fatalf("expected allow when require_signed not set, got: %v", err)
}
}
+
+// --- L9: Sealed Response Policy Tests ---
+
+func TestRequireSealedDeniesWithoutKey(t *testing.T) {
+ policyJSON := `{"attestation":{"secure/*":{"require_sealed":true}}}`
+ e, err := Load([]byte(policyJSON))
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+
+ ctx := &RequestContext{SealKeyPresented: false}
+ err = e.Evaluate("secure/api-key", ctx)
+ if err == nil {
+ t.Fatal("expected denial when require_sealed and no seal key")
+ }
+ denied, ok := err.(*DeniedError)
+ if !ok {
+ t.Fatalf("expected DeniedError, got %T", err)
+ }
+ if !strings.Contains(denied.Reason, "sealed response required") {
+ t.Errorf("unexpected reason: %s", denied.Reason)
+ }
+}
+
+func TestRequireSealedAllowsWithKey(t *testing.T) {
+ policyJSON := `{"attestation":{"secure/*":{"require_sealed":true}}}`
+ e, err := Load([]byte(policyJSON))
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+
+ ctx := &RequestContext{SealKeyPresented: true}
+ if err := e.Evaluate("secure/api-key", ctx); err != nil {
+ t.Fatalf("expected allow with seal key, got: %v", err)
+ }
+}
+
+func TestRequireSealedNoEffectOnUnmatchedPaths(t *testing.T) {
+ policyJSON := `{"attestation":{"secure/*":{"require_sealed":true}}}`
+ e, err := Load([]byte(policyJSON))
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+
+ // Path that doesn't match the policy
+ ctx := &RequestContext{SealKeyPresented: false}
+ if err := e.Evaluate("open/key", ctx); err != nil {
+ t.Fatalf("expected allow for unmatched path, got: %v", err)
+ }
+}
+
+func TestAllowUnsealParseBackwardCompat(t *testing.T) {
+ // allow_unseal should parse without breaking anything
+ policyJSON := `{"attestation":{"ns/*":{"allow_unseal":true}}}`
+ e, err := Load([]byte(policyJSON))
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+
+ rule, _ := e.RuleFor("ns/secret")
+ if rule == nil {
+ t.Fatal("expected rule for ns/secret")
+ }
+ if !rule.AllowUnseal {
+ t.Error("expected AllowUnseal=true")
+ }
+
+ // Should not affect evaluation (allow_unseal is not an access check)
+ ctx := &RequestContext{}
+ if err := e.Evaluate("ns/secret", ctx); err != nil {
+ t.Fatalf("allow_unseal should not deny access: %v", err)
+ }
+}
+
+func TestRequireSealedWithOtherChecks(t *testing.T) {
+ policyJSON := `{"attestation":{"secure/*":{"require_sealed":true,"require_nonce":true}}}`
+ e, err := Load([]byte(policyJSON))
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+
+ // Seal key but no nonce — should fail on nonce check (L8 before L9)
+ ctx := &RequestContext{SealKeyPresented: true, NonceValidated: false}
+ err = e.Evaluate("secure/key", ctx)
+ if err == nil {
+ t.Fatal("expected denial for missing nonce")
+ }
+ if !strings.Contains(err.Error(), "nonce") {
+ t.Errorf("expected nonce denial, got: %v", err)
+ }
+
+ // Nonce but no seal key — should fail on sealed check (L9)
+ ctx = &RequestContext{SealKeyPresented: false, NonceValidated: true}
+ err = e.Evaluate("secure/key", ctx)
+ if err == nil {
+ t.Fatal("expected denial for missing seal key")
+ }
+ if !strings.Contains(err.Error(), "sealed") {
+ t.Errorf("expected sealed denial, got: %v", err)
+ }
+
+ // Both present — should pass
+ ctx = &RequestContext{SealKeyPresented: true, NonceValidated: true}
+ if err := e.Evaluate("secure/key", ctx); err != nil {
+ t.Fatalf("expected allow with both, got: %v", err)
+ }
+}
+
+func TestNoNewFieldsNoChange(t *testing.T) {
+ // Existing policy without any sealed fields should work as before
+ policyJSON := `{"attestation":{"ns/*":{"require_mtls":true}}}`
+ e, err := Load([]byte(policyJSON))
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+
+ ctx := &RequestContext{UsedMTLS: true}
+ if err := e.Evaluate("ns/key", ctx); err != nil {
+ t.Fatalf("expected allow, got: %v", err)
+ }
+
+ rule, _ := e.RuleFor("ns/key")
+ if rule.RequireSealed {
+ t.Error("RequireSealed should default to false")
+ }
+ if rule.AllowUnseal {
+ t.Error("AllowUnseal should default to false")
+ }
+}
+
+func TestRequireSealedLegacyFormat(t *testing.T) {
+ policyJSON := `{"rules":[{"path":"secure/*","require_sealed":true,"allow_unseal":true}]}`
+ e, err := Load([]byte(policyJSON))
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+
+ rule, _ := e.RuleFor("secure/key")
+ if rule == nil {
+ t.Fatal("expected rule")
+ }
+ if !rule.RequireSealed {
+ t.Error("expected RequireSealed=true from legacy format")
+ }
+ if !rule.AllowUnseal {
+ t.Error("expected AllowUnseal=true from legacy format")
+ }
+}
diff --git a/internal/version/version.go b/internal/version/version.go
index 87278f0..6532b3e 100644
--- a/internal/version/version.go
+++ b/internal/version/version.go
@@ -4,4 +4,4 @@
// go build -ldflags "-X github.com/phoenixsec/phoenix/internal/version.Version=1.0.0"
package version
-var Version = "0.10.5"
+var Version = "1.0.0"
diff --git a/phoenix-skill/SKILL.md b/phoenix-skill/SKILL.md
index 766815d..ba5d804 100644
--- a/phoenix-skill/SKILL.md
+++ b/phoenix-skill/SKILL.md
@@ -21,6 +21,7 @@ environment variables before use:
- `PHOENIX_TOKEN` — bearer token for authentication
- `PHOENIX_CA_CERT` — CA certificate path (for TLS)
- `PHOENIX_CLIENT_CERT` / `PHOENIX_CLIENT_KEY` — mTLS client auth
+- `PHOENIX_SEAL_KEY` — seal private key file path (enables sealed mode)
### Preferred: run a command with secrets injected
@@ -151,6 +152,18 @@ phoenix audit [-n 20] [-a agent-name] [-s 2025-01-01T00:00:00Z]
Query the audit log. Filter by count, agent, or time range.
+### Generate a seal key pair
+
+```bash
+phoenix keypair generate [-o /path/to/keys/]
+```
+
+Writes `.seal.key` (private key, mode `0600`) and prints the derived
+public key to stdout. Set `PHOENIX_SEAL_KEY` to the private key path to enable.
+
+When sealed mode is active, `phoenix get`, `phoenix resolve`, and
+`phoenix exec` transparently encrypt/decrypt values using NaCl box.
+
## Safety Guardrails
- **Never ask the user to paste secrets into chat** — use `phoenix set --value-stdin`
diff --git a/scripts/install.sh b/scripts/install.sh
index 943f738..4c0e88d 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -1,7 +1,7 @@
#!/usr/bin/env sh
set -eu
-REPO="phoenixsec/phoenix"
+REPO="phoenixsec-dev/phoenix"
INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}"
VERSION="${PHOENIX_VERSION:-latest}" # e.g. v0.10.2 or "latest"
diff --git a/sdk/go/phoenix/client.go b/sdk/go/phoenix/client.go
index 887bd74..ae9c66d 100644
--- a/sdk/go/phoenix/client.go
+++ b/sdk/go/phoenix/client.go
@@ -34,11 +34,24 @@ type ResolveResult struct {
Errors map[string]string `json:"errors,omitempty"`
}
+// SealedEnvelope is the wire format for a sealed secret value.
+type SealedEnvelope struct {
+ Version int `json:"version"`
+ Algorithm string `json:"algorithm"`
+ Path string `json:"path"`
+ Ref string `json:"ref"`
+ EphemeralKey string `json:"ephemeral_key"`
+ Nonce string `json:"nonce"`
+ Ciphertext string `json:"ciphertext"`
+}
+
// Client is a thin HTTP client for the Phoenix API.
type Client struct {
Server string
Token string
HTTPClient *http.Client
+ sealPubKey string // base64-encoded public key for sealed requests
+ sealPriv *[32]byte // private key for decrypting sealed responses
}
// New creates a new Phoenix client. Server and token default to
@@ -65,6 +78,22 @@ func New(server, token string) *Client {
}
}
+// SetSealKey loads a seal private key from a file, enabling sealed mode.
+// When set, Resolve and ResolveBatch auto-decrypt sealed responses.
+func (c *Client) SetSealKey(path string) error {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return fmt.Errorf("reading seal key: %w", err)
+ }
+ privKey, err := decodeSealKey(strings.TrimSpace(string(data)))
+ if err != nil {
+ return err
+ }
+ c.sealPriv = privKey
+ c.sealPubKey = encodeSealKey(deriveSealPublicKey(privKey))
+ return nil
+}
+
// Health checks server health. Returns the parsed JSON response.
func (c *Client) Health() (map[string]interface{}, error) {
var result map[string]interface{}
@@ -91,12 +120,51 @@ func (c *Client) Resolve(ref string) (string, error) {
}
// ResolveBatch resolves multiple phoenix:// references in one API call.
+// When sealed mode is enabled (via SetSealKey), responses are automatically
+// decrypted — callers see plaintext values transparently.
func (c *Client) ResolveBatch(refs []string) (*ResolveResult, error) {
if len(refs) == 0 {
return nil, &Error{Message: "refs must not be empty"}
}
body := map[string]interface{}{"refs": refs}
+
+ if c.sealPriv != nil {
+ // Sealed mode: parse sealed_values and decrypt locally,
+ // falling back to plaintext values if server doesn't seal.
+ var raw struct {
+ SealedValues map[string]json.RawMessage `json:"sealed_values"`
+ Values map[string]string `json:"values"`
+ Errors map[string]string `json:"errors,omitempty"`
+ }
+ if err := c.doRequest("POST", "/v1/resolve", body, &raw); err != nil {
+ return nil, err
+ }
+ if len(raw.SealedValues) > 0 {
+ result := &ResolveResult{
+ Values: make(map[string]string, len(raw.SealedValues)),
+ Errors: raw.Errors,
+ }
+ for ref, envJSON := range raw.SealedValues {
+ var env SealedEnvelope
+ if err := json.Unmarshal(envJSON, &env); err != nil {
+ return nil, &Error{Message: fmt.Sprintf("parsing sealed envelope for %s: %v", ref, err)}
+ }
+ if env.Ref != ref {
+ return nil, &Error{Message: fmt.Sprintf("sealed envelope ref mismatch: map key %q, envelope %q", ref, env.Ref)}
+ }
+ val, err := openSealedEnvelope(&env, c.sealPriv)
+ if err != nil {
+ return nil, &Error{Message: fmt.Sprintf("decrypting %s: %v", ref, err)}
+ }
+ result.Values[ref] = val
+ }
+ return result, nil
+ }
+ // Fallback: server returned plaintext values
+ return &ResolveResult{Values: raw.Values, Errors: raw.Errors}, nil
+ }
+
var result ResolveResult
if err := c.doRequest("POST", "/v1/resolve", body, &result); err != nil {
return nil, err
@@ -124,6 +192,9 @@ func (c *Client) doRequest(method, path string, body interface{}, out interface{
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
+ if c.sealPubKey != "" {
+ req.Header.Set("X-Phoenix-Seal-Key", c.sealPubKey)
+ }
resp, err := c.HTTPClient.Do(req)
if err != nil {
diff --git a/sdk/go/phoenix/seal.go b/sdk/go/phoenix/seal.go
new file mode 100644
index 0000000..1195c4e
--- /dev/null
+++ b/sdk/go/phoenix/seal.go
@@ -0,0 +1,90 @@
+package phoenix
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+
+ "golang.org/x/crypto/curve25519"
+ "golang.org/x/crypto/nacl/box"
+)
+
+// openSealedEnvelope decrypts a sealed envelope using the recipient's private key.
+func openSealedEnvelope(env *SealedEnvelope, privKey *[32]byte) (string, error) {
+ if env.Version != 1 {
+ return "", fmt.Errorf("unsupported seal version: %d", env.Version)
+ }
+ if env.Algorithm != "x25519-xsalsa20-poly1305" {
+ return "", fmt.Errorf("unsupported seal algorithm: %s", env.Algorithm)
+ }
+
+ ephPub, err := base64.StdEncoding.DecodeString(env.EphemeralKey)
+ if err != nil || len(ephPub) != 32 {
+ return "", fmt.Errorf("invalid ephemeral key")
+ }
+ nonce, err := base64.StdEncoding.DecodeString(env.Nonce)
+ if err != nil || len(nonce) != 24 {
+ return "", fmt.Errorf("invalid nonce")
+ }
+ ciphertext, err := base64.StdEncoding.DecodeString(env.Ciphertext)
+ if err != nil {
+ return "", fmt.Errorf("invalid ciphertext")
+ }
+
+ var ephPubKey [32]byte
+ var nonceArr [24]byte
+ copy(ephPubKey[:], ephPub)
+ copy(nonceArr[:], nonce)
+
+ plaintext, ok := box.Open(nil, ciphertext, &nonceArr, &ephPubKey, privKey)
+ if !ok {
+ return "", fmt.Errorf("decryption failed")
+ }
+
+ var payload struct {
+ Path string `json:"path"`
+ Ref string `json:"ref"`
+ Value string `json:"value"`
+ IssuedAt string `json:"issued_at"`
+ }
+ if err := json.Unmarshal(plaintext, &payload); err != nil {
+ return "", fmt.Errorf("parsing decrypted payload: %v", err)
+ }
+
+ // Verify inner/outer binding
+ if payload.Path != env.Path {
+ return "", fmt.Errorf("path mismatch: inner=%q outer=%q", payload.Path, env.Path)
+ }
+ if payload.Ref != env.Ref {
+ return "", fmt.Errorf("ref mismatch: inner=%q outer=%q", payload.Ref, env.Ref)
+ }
+
+ return payload.Value, nil
+}
+
+// decodeSealKey decodes a base64-encoded 32-byte key.
+func decodeSealKey(encoded string) (*[32]byte, error) {
+ raw, err := base64.StdEncoding.DecodeString(encoded)
+ if err != nil {
+ return nil, fmt.Errorf("decoding seal key: %w", err)
+ }
+ if len(raw) != 32 {
+ return nil, fmt.Errorf("seal key must be 32 bytes, got %d", len(raw))
+ }
+ var key [32]byte
+ copy(key[:], raw)
+ return &key, nil
+}
+
+// encodeSealKey encodes a 32-byte key to base64.
+func encodeSealKey(key *[32]byte) string {
+ return base64.StdEncoding.EncodeToString(key[:])
+}
+
+// deriveSealPublicKey derives the X25519 public key from a private key.
+func deriveSealPublicKey(privKey *[32]byte) *[32]byte {
+ var pubKey [32]byte
+ pub, _ := curve25519.X25519(privKey[:], curve25519.Basepoint)
+ copy(pubKey[:], pub)
+ return &pubKey
+}
diff --git a/sdk/go/phoenix/seal_test.go b/sdk/go/phoenix/seal_test.go
new file mode 100644
index 0000000..c43dbeb
--- /dev/null
+++ b/sdk/go/phoenix/seal_test.go
@@ -0,0 +1,228 @@
+package phoenix
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "golang.org/x/crypto/nacl/box"
+)
+
+// sealValue creates a sealed envelope for testing (mirrors internal/crypto.SealValue).
+func sealValue(path, ref, value string, recipientPubKey *[32]byte) *SealedEnvelope {
+ ephPub, ephPriv, _ := box.GenerateKey(rand.Reader)
+
+ payload, _ := json.Marshal(map[string]string{
+ "path": path,
+ "ref": ref,
+ "value": value,
+ "issued_at": time.Now().UTC().Format(time.RFC3339),
+ })
+
+ var nonce [24]byte
+ rand.Read(nonce[:])
+
+ ciphertext := box.Seal(nil, payload, &nonce, recipientPubKey, ephPriv)
+
+ return &SealedEnvelope{
+ Version: 1,
+ Algorithm: "x25519-xsalsa20-poly1305",
+ Path: path,
+ Ref: ref,
+ EphemeralKey: base64.StdEncoding.EncodeToString(ephPub[:]),
+ Nonce: base64.StdEncoding.EncodeToString(nonce[:]),
+ Ciphertext: base64.StdEncoding.EncodeToString(ciphertext),
+ }
+}
+
+func TestSetSealKeyAndResolveSealed(t *testing.T) {
+ // Generate a keypair
+ pub, priv, _ := box.GenerateKey(rand.Reader)
+
+ // Write private key to temp file
+ keyPath := filepath.Join(t.TempDir(), "test.seal.key")
+ os.WriteFile(keyPath, []byte(encodeSealKey(priv)), 0600)
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ sealKey := r.Header.Get("X-Phoenix-Seal-Key")
+ if sealKey == "" {
+ t.Error("expected X-Phoenix-Seal-Key header")
+ }
+ // Verify the public key matches
+ expected := encodeSealKey(pub)
+ if sealKey != expected {
+ t.Errorf("seal key = %q, want %q", sealKey, expected)
+ }
+
+ env := sealValue("ns/key", "phoenix://ns/key", "sealed-secret", pub)
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "sealed_values": map[string]interface{}{
+ "phoenix://ns/key": env,
+ },
+ })
+ }))
+ defer srv.Close()
+
+ c := New(srv.URL, "test-token")
+ if err := c.SetSealKey(keyPath); err != nil {
+ t.Fatalf("SetSealKey: %v", err)
+ }
+
+ val, err := c.Resolve("phoenix://ns/key")
+ if err != nil {
+ t.Fatalf("Resolve: %v", err)
+ }
+ if val != "sealed-secret" {
+ t.Fatalf("got %q, want %q", val, "sealed-secret")
+ }
+}
+
+func TestResolveBatchSealed(t *testing.T) {
+ pub, priv, _ := box.GenerateKey(rand.Reader)
+
+ keyPath := filepath.Join(t.TempDir(), "test.seal.key")
+ os.WriteFile(keyPath, []byte(encodeSealKey(priv)), 0600)
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ env1 := sealValue("ns/k1", "phoenix://ns/k1", "val1", pub)
+ env2 := sealValue("ns/k2", "phoenix://ns/k2", "val2", pub)
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "sealed_values": map[string]interface{}{
+ "phoenix://ns/k1": env1,
+ "phoenix://ns/k2": env2,
+ },
+ })
+ }))
+ defer srv.Close()
+
+ c := New(srv.URL, "tok")
+ c.SetSealKey(keyPath)
+
+ result, err := c.ResolveBatch([]string{"phoenix://ns/k1", "phoenix://ns/k2"})
+ if err != nil {
+ t.Fatalf("ResolveBatch: %v", err)
+ }
+ if result.Values["phoenix://ns/k1"] != "val1" {
+ t.Fatalf("k1 = %q, want %q", result.Values["phoenix://ns/k1"], "val1")
+ }
+ if result.Values["phoenix://ns/k2"] != "val2" {
+ t.Fatalf("k2 = %q, want %q", result.Values["phoenix://ns/k2"], "val2")
+ }
+}
+
+func TestPlaintextBackwardCompat(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("X-Phoenix-Seal-Key") != "" {
+ t.Error("should not send seal header without key")
+ }
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "values": map[string]string{"phoenix://ns/key": "plain-val"},
+ })
+ }))
+ defer srv.Close()
+
+ c := New(srv.URL, "tok")
+ val, err := c.Resolve("phoenix://ns/key")
+ if err != nil {
+ t.Fatalf("Resolve: %v", err)
+ }
+ if val != "plain-val" {
+ t.Fatalf("got %q, want %q", val, "plain-val")
+ }
+}
+
+func TestSealedWrongKeyReject(t *testing.T) {
+ pub1, _, _ := box.GenerateKey(rand.Reader)
+ _, priv2, _ := box.GenerateKey(rand.Reader)
+
+ keyPath := filepath.Join(t.TempDir(), "test.seal.key")
+ os.WriteFile(keyPath, []byte(encodeSealKey(priv2)), 0600)
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Seal with pub1 but client has priv2
+ env := sealValue("ns/key", "phoenix://ns/key", "secret", pub1)
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "sealed_values": map[string]interface{}{
+ "phoenix://ns/key": env,
+ },
+ })
+ }))
+ defer srv.Close()
+
+ c := New(srv.URL, "tok")
+ c.SetSealKey(keyPath)
+
+ _, err := c.Resolve("phoenix://ns/key")
+ if err == nil {
+ t.Fatal("expected error decrypting with wrong key")
+ }
+}
+
+func TestSetSealKeyBadFile(t *testing.T) {
+ c := New("http://localhost:9999", "tok")
+ err := c.SetSealKey("/nonexistent/path")
+ if err == nil {
+ t.Fatal("expected error for missing file")
+ }
+}
+
+func TestSetSealKeyBadContent(t *testing.T) {
+ keyPath := filepath.Join(t.TempDir(), "bad.key")
+ os.WriteFile(keyPath, []byte("not-valid-base64!!!"), 0600)
+
+ c := New("http://localhost:9999", "tok")
+ err := c.SetSealKey(keyPath)
+ if err == nil {
+ t.Fatal("expected error for invalid key content")
+ }
+}
+
+func TestResolveBatchRefSwapRejected(t *testing.T) {
+ pub, priv, _ := box.GenerateKey(rand.Reader)
+
+ keyPath := filepath.Join(t.TempDir(), "test.seal.key")
+ os.WriteFile(keyPath, []byte(encodeSealKey(priv)), 0600)
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Server swaps the map keys: envelope for A is filed under B and vice versa
+ envA := sealValue("ns/a", "phoenix://ns/a", "A", pub)
+ envB := sealValue("ns/b", "phoenix://ns/b", "B", pub)
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "sealed_values": map[string]interface{}{
+ "phoenix://ns/a": envB, // swapped!
+ "phoenix://ns/b": envA, // swapped!
+ },
+ })
+ }))
+ defer srv.Close()
+
+ c := New(srv.URL, "tok")
+ c.SetSealKey(keyPath)
+
+ _, err := c.ResolveBatch([]string{"phoenix://ns/a", "phoenix://ns/b"})
+ if err == nil {
+ t.Fatal("expected error for ref-swap attack, got nil")
+ }
+}
+
+func TestOpenSealedEnvelopeBadVersion(t *testing.T) {
+ env := &SealedEnvelope{Version: 99, Algorithm: "x25519-xsalsa20-poly1305"}
+ _, err := openSealedEnvelope(env, &[32]byte{})
+ if err == nil {
+ t.Fatal("expected error for bad version")
+ }
+}
+
+func TestOpenSealedEnvelopeBadAlgorithm(t *testing.T) {
+ env := &SealedEnvelope{Version: 1, Algorithm: "aes-gcm"}
+ _, err := openSealedEnvelope(env, &[32]byte{})
+ if err == nil {
+ t.Fatal("expected error for bad algorithm")
+ }
+}
diff --git a/sdk/python/phoenix_secrets/client.py b/sdk/python/phoenix_secrets/client.py
index 3a5dce6..1f67bb1 100644
--- a/sdk/python/phoenix_secrets/client.py
+++ b/sdk/python/phoenix_secrets/client.py
@@ -1,17 +1,26 @@
"""Thin HTTP client for the Phoenix secrets management API.
Supports resolve, batch resolve, and health check. No admin operations.
-Under 200 lines — if this grows beyond that, it's doing too much.
+Sealed mode auto-decrypts when PyNaCl is installed and a seal key is configured.
"""
from __future__ import annotations
+import base64
import json
import os
from typing import Optional
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
+try:
+ import nacl.public
+ import nacl.utils
+
+ _HAS_NACL = True
+except ImportError:
+ _HAS_NACL = False
+
class PhoenixError(Exception):
"""Raised when a Phoenix API call fails."""
@@ -42,6 +51,7 @@ def __init__(
server: Optional[str] = None,
token: Optional[str] = None,
timeout: int = 10,
+ seal_key_path: Optional[str] = None,
):
self.server = (
server or os.environ.get("PHOENIX_SERVER") or "http://127.0.0.1:9090"
@@ -49,6 +59,35 @@ def __init__(
self.server = self.server.rstrip("/")
self.token = token or os.environ.get("PHOENIX_TOKEN", "")
self.timeout = timeout
+ self._seal_priv: Optional[bytes] = None
+ self._seal_pub_b64: Optional[str] = None
+
+ key_path = seal_key_path or os.environ.get("PHOENIX_SEAL_KEY")
+ if key_path:
+ self.set_seal_key(key_path)
+
+ def set_seal_key(self, path: str) -> None:
+ """Load a seal private key, enabling sealed mode.
+
+ Requires ``pip install phoenix-secrets[sealed]`` (PyNaCl).
+ """
+ if not _HAS_NACL:
+ raise PhoenixError(
+ "PyNaCl is required for sealed mode: pip install phoenix-secrets[sealed]"
+ )
+ with open(path) as f:
+ raw = f.read().strip()
+ try:
+ priv_bytes = base64.b64decode(raw)
+ except Exception as e:
+ raise PhoenixError(f"invalid seal key encoding: {e}") from None
+ if len(priv_bytes) != 32:
+ raise PhoenixError(f"seal key must be 32 bytes, got {len(priv_bytes)}")
+ self._seal_priv = priv_bytes
+ priv_key = nacl.public.PrivateKey(priv_bytes)
+ self._seal_pub_b64 = base64.b64encode(
+ priv_key.public_key.encode()
+ ).decode()
def _request(
self, method: str, path: str, body: Optional[dict] = None
@@ -62,6 +101,8 @@ def _request(
req.add_header("Authorization", f"Bearer {self.token}")
if data:
req.add_header("Content-Type", "application/json")
+ if self._seal_pub_b64:
+ req.add_header("X-Phoenix-Seal-Key", self._seal_pub_b64)
try:
with urlopen(req, timeout=self.timeout) as resp:
@@ -108,6 +149,8 @@ def resolve(self, ref: str) -> str:
def resolve_batch(self, refs: list[str]) -> dict:
"""Resolve multiple ``phoenix://`` references in one API call.
+ When sealed mode is enabled, responses are auto-decrypted transparently.
+
Args:
refs: List of phoenix:// references.
@@ -120,7 +163,48 @@ def resolve_batch(self, refs: list[str]) -> dict:
"""
if not refs:
raise PhoenixError("refs must not be empty")
- return self._request("POST", "/v1/resolve", {"refs": refs})
+ result = self._request("POST", "/v1/resolve", {"refs": refs})
+
+ if self._seal_priv and "sealed_values" in result:
+ values = {}
+ for ref, env in result["sealed_values"].items():
+ if env.get("ref") != ref:
+ raise PhoenixError(
+ f"sealed envelope ref mismatch: map key {ref!r}, envelope {env.get('ref')!r}"
+ )
+ values[ref] = self._unseal_envelope(env)
+ result["values"] = values
+ del result["sealed_values"]
+
+ return result
+
+ def _unseal_envelope(self, env: dict) -> str:
+ """Decrypt a sealed envelope using the loaded private key."""
+ if not _HAS_NACL or not self._seal_priv:
+ raise PhoenixError("sealed mode not configured")
+
+ if env.get("version") != 1:
+ raise PhoenixError(f"unsupported seal version: {env.get('version')}")
+ if env.get("algorithm") != "x25519-xsalsa20-poly1305":
+ raise PhoenixError(f"unsupported seal algorithm: {env.get('algorithm')}")
+
+ eph_pub = base64.b64decode(env["ephemeral_key"])
+ nonce = base64.b64decode(env["nonce"])
+ ciphertext = base64.b64decode(env["ciphertext"])
+
+ priv_key = nacl.public.PrivateKey(self._seal_priv)
+ eph_pub_key = nacl.public.PublicKey(eph_pub)
+ box = nacl.public.Box(priv_key, eph_pub_key)
+
+ plaintext = box.decrypt(ciphertext, nonce)
+ payload = json.loads(plaintext)
+
+ if payload.get("path") != env.get("path"):
+ raise PhoenixError("path mismatch in sealed envelope")
+ if payload.get("ref") != env.get("ref"):
+ raise PhoenixError("ref mismatch in sealed envelope")
+
+ return payload["value"]
def verify(self, refs: list[str]) -> dict:
"""Dry-run resolve — check that references are valid and accessible
diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml
index 8cd2dab..d07b257 100644
--- a/sdk/python/pyproject.toml
+++ b/sdk/python/pyproject.toml
@@ -8,9 +8,13 @@ version = "0.1.0"
description = "Python client for the Phoenix secrets management API"
requires-python = ">=3.9"
license = "MIT"
+dependencies = []
+
+[project.optional-dependencies]
+sealed = ["PyNaCl>=1.5.0"]
[project.urls]
-Repository = "https://github.com/phoenixsec/phoenix"
+Repository = "https://github.com/phoenixsec-dev/phoenix"
[tool.setuptools.packages.find]
include = ["phoenix_secrets*"]
diff --git a/sdk/python/tests/test_sealed.py b/sdk/python/tests/test_sealed.py
new file mode 100644
index 0000000..ba02862
--- /dev/null
+++ b/sdk/python/tests/test_sealed.py
@@ -0,0 +1,255 @@
+"""Unit tests for sealed mode in the Phoenix Python SDK."""
+
+import base64
+import json
+import os
+import tempfile
+import time
+import unittest
+from http.server import HTTPServer, BaseHTTPRequestHandler
+from threading import Thread
+
+try:
+ import nacl.public
+ import nacl.utils
+ _HAS_NACL = True
+except ImportError:
+ _HAS_NACL = False
+
+from phoenix_secrets.client import PhoenixClient, PhoenixError
+
+
+def _generate_keypair():
+ """Generate an X25519 keypair for testing."""
+ priv = nacl.public.PrivateKey.generate()
+ return priv, priv.public_key
+
+
+def _seal_value(path, ref, value, recipient_pub):
+ """Create a sealed envelope for testing."""
+ eph = nacl.public.PrivateKey.generate()
+ box = nacl.public.Box(eph, recipient_pub)
+ nonce = nacl.utils.random(24)
+
+ payload = json.dumps({
+ "path": path,
+ "ref": ref,
+ "value": value,
+ "issued_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
+ }).encode()
+
+ ciphertext = box.encrypt(payload, nonce).ciphertext
+
+ return {
+ "version": 1,
+ "algorithm": "x25519-xsalsa20-poly1305",
+ "path": path,
+ "ref": ref,
+ "ephemeral_key": base64.b64encode(eph.public_key.encode()).decode(),
+ "nonce": base64.b64encode(nonce).decode(),
+ "ciphertext": base64.b64encode(ciphertext).decode(),
+ }
+
+
+def _write_key_file(priv_key):
+ """Write a private key to a temp file and return the path."""
+ fd, path = tempfile.mkstemp(suffix=".seal.key")
+ os.write(fd, base64.b64encode(priv_key.encode()))
+ os.close(fd)
+ os.chmod(path, 0o600)
+ return path
+
+
+class MockSealedServer(BaseHTTPRequestHandler):
+ """Mock Phoenix server that returns sealed responses."""
+
+ pub_key = None # Set by test
+
+ def do_POST(self):
+ if self.path == "/v1/resolve":
+ content_len = int(self.headers.get("Content-Length", 0))
+ body = json.loads(self.rfile.read(content_len))
+ refs = body.get("refs", [])
+
+ seal_key_header = self.headers.get("X-Phoenix-Seal-Key", "")
+ if seal_key_header and self.pub_key:
+ pub_bytes = base64.b64decode(seal_key_header)
+ pub = nacl.public.PublicKey(pub_bytes)
+ sealed_values = {}
+ for ref in refs:
+ # Extract path from ref (phoenix://path)
+ path = ref.replace("phoenix://", "")
+ sealed_values[ref] = _seal_value(path, ref, f"secret-for-{path}", pub)
+ resp = json.dumps({"sealed_values": sealed_values}).encode()
+ else:
+ values = {ref: f"plain-{ref}" for ref in refs}
+ resp = json.dumps({"values": values}).encode()
+
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.end_headers()
+ self.wfile.write(resp)
+ else:
+ self.send_response(404)
+ self.end_headers()
+
+ def log_message(self, format, *args):
+ pass # Suppress output
+
+
+@unittest.skipUnless(_HAS_NACL, "PyNaCl not installed (pip install phoenix-secrets[sealed])")
+class TestSealedMode(unittest.TestCase):
+
+ @classmethod
+ def setUpClass(cls):
+ cls.priv, cls.pub = _generate_keypair()
+ MockSealedServer.pub_key = cls.pub
+ cls.server = HTTPServer(("127.0.0.1", 0), MockSealedServer)
+ cls.port = cls.server.server_address[1]
+ cls.thread = Thread(target=cls.server.serve_forever, daemon=True)
+ cls.thread.start()
+
+ @classmethod
+ def tearDownClass(cls):
+ cls.server.shutdown()
+
+ def test_sealed_resolve(self):
+ key_path = _write_key_file(self.priv)
+ try:
+ client = PhoenixClient(
+ server=f"http://127.0.0.1:{self.port}",
+ token="test",
+ seal_key_path=key_path,
+ )
+ val = client.resolve("phoenix://ns/secret")
+ self.assertEqual(val, "secret-for-ns/secret")
+ finally:
+ os.unlink(key_path)
+
+ def test_sealed_batch_resolve(self):
+ key_path = _write_key_file(self.priv)
+ try:
+ client = PhoenixClient(
+ server=f"http://127.0.0.1:{self.port}",
+ token="test",
+ seal_key_path=key_path,
+ )
+ result = client.resolve_batch([
+ "phoenix://ns/k1",
+ "phoenix://ns/k2",
+ ])
+ self.assertEqual(result["values"]["phoenix://ns/k1"], "secret-for-ns/k1")
+ self.assertEqual(result["values"]["phoenix://ns/k2"], "secret-for-ns/k2")
+ finally:
+ os.unlink(key_path)
+
+ def test_plaintext_without_seal_key(self):
+ client = PhoenixClient(
+ server=f"http://127.0.0.1:{self.port}",
+ token="test",
+ )
+ val = client.resolve("phoenix://ns/secret")
+ self.assertEqual(val, "plain-phoenix://ns/secret")
+
+ def test_unseal_bad_version(self):
+ key_path = _write_key_file(self.priv)
+ try:
+ client = PhoenixClient(
+ server=f"http://127.0.0.1:{self.port}",
+ token="test",
+ seal_key_path=key_path,
+ )
+ env = {"version": 99, "algorithm": "x25519-xsalsa20-poly1305"}
+ with self.assertRaises(PhoenixError) as ctx:
+ client._unseal_envelope(env)
+ self.assertIn("version", str(ctx.exception))
+ finally:
+ os.unlink(key_path)
+
+ def test_unseal_bad_algorithm(self):
+ key_path = _write_key_file(self.priv)
+ try:
+ client = PhoenixClient(
+ server=f"http://127.0.0.1:{self.port}",
+ token="test",
+ seal_key_path=key_path,
+ )
+ env = {"version": 1, "algorithm": "aes-gcm"}
+ with self.assertRaises(PhoenixError) as ctx:
+ client._unseal_envelope(env)
+ self.assertIn("algorithm", str(ctx.exception))
+ finally:
+ os.unlink(key_path)
+
+ def test_set_seal_key_bad_file(self):
+ with self.assertRaises(Exception):
+ client = PhoenixClient(
+ server="http://localhost:9999",
+ token="test",
+ seal_key_path="/nonexistent/path",
+ )
+
+ def test_set_seal_key_bad_content(self):
+ fd, path = tempfile.mkstemp()
+ os.write(fd, b"not-valid-base64!!!")
+ os.close(fd)
+ try:
+ with self.assertRaises(PhoenixError):
+ client = PhoenixClient(
+ server="http://localhost:9999",
+ token="test",
+ seal_key_path=path,
+ )
+ finally:
+ os.unlink(path)
+
+
+ def test_ref_swap_attack_rejected(self):
+ """Server swaps map keys — client must reject."""
+ key_path = _write_key_file(self.priv)
+ try:
+ client = PhoenixClient(
+ server=f"http://127.0.0.1:{self.port}",
+ token="test",
+ seal_key_path=key_path,
+ )
+ # Monkey-patch the mock to return swapped envelopes
+ original_do_POST = MockSealedServer.do_POST
+
+ def swapped_handler(handler_self):
+ if handler_self.path == "/v1/resolve":
+ content_len = int(handler_self.headers.get("Content-Length", 0))
+ body = json.loads(handler_self.rfile.read(content_len))
+ seal_key_header = handler_self.headers.get("X-Phoenix-Seal-Key", "")
+ pub_bytes = base64.b64decode(seal_key_header)
+ pub = nacl.public.PublicKey(pub_bytes)
+ # Create envelopes with correct refs but swap map keys
+ env_a = _seal_value("ns/a", "phoenix://ns/a", "A", pub)
+ env_b = _seal_value("ns/b", "phoenix://ns/b", "B", pub)
+ resp = json.dumps({
+ "sealed_values": {
+ "phoenix://ns/a": env_b, # swapped!
+ "phoenix://ns/b": env_a, # swapped!
+ }
+ }).encode()
+ handler_self.send_response(200)
+ handler_self.send_header("Content-Type", "application/json")
+ handler_self.end_headers()
+ handler_self.wfile.write(resp)
+
+ MockSealedServer.do_POST = swapped_handler
+ try:
+ with self.assertRaises(PhoenixError) as ctx:
+ client.resolve_batch([
+ "phoenix://ns/a",
+ "phoenix://ns/b",
+ ])
+ self.assertIn("mismatch", str(ctx.exception))
+ finally:
+ MockSealedServer.do_POST = original_do_POST
+ finally:
+ os.unlink(key_path)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/sdk/typescript/package-lock.json b/sdk/typescript/package-lock.json
new file mode 100644
index 0000000..b9058b4
--- /dev/null
+++ b/sdk/typescript/package-lock.json
@@ -0,0 +1,22 @@
+{
+ "name": "phoenix-secrets",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "phoenix-secrets",
+ "version": "0.1.0",
+ "license": "MIT",
+ "optionalDependencies": {
+ "tweetnacl": "^1.0.3"
+ }
+ },
+ "node_modules/tweetnacl": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
+ "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
+ "optional": true
+ }
+ }
+}
diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json
index 87f2982..dd9306f 100644
--- a/sdk/typescript/package.json
+++ b/sdk/typescript/package.json
@@ -8,5 +8,8 @@
"test": "node --test src/client.test.js"
},
"license": "MIT",
- "keywords": ["secrets", "phoenix", "ai-agents", "security"]
+ "keywords": ["secrets", "phoenix", "ai-agents", "security"],
+ "optionalDependencies": {
+ "tweetnacl": "^1.0.3"
+ }
}
diff --git a/sdk/typescript/src/client.d.ts b/sdk/typescript/src/client.d.ts
index c8f5bbb..1d0703b 100644
--- a/sdk/typescript/src/client.d.ts
+++ b/sdk/typescript/src/client.d.ts
@@ -12,6 +12,7 @@ export interface PhoenixClientOptions {
server?: string;
token?: string;
timeout?: number;
+ sealKeyPath?: string;
}
export class PhoenixClient {
@@ -21,6 +22,7 @@ export class PhoenixClient {
constructor(options?: PhoenixClientOptions);
+ setSealKey(path: string): Promise;
health(): Promise>;
resolve(ref: string): Promise;
resolveBatch(refs: string[]): Promise;
diff --git a/sdk/typescript/src/client.js b/sdk/typescript/src/client.js
index c3ee8e3..845e34a 100644
--- a/sdk/typescript/src/client.js
+++ b/sdk/typescript/src/client.js
@@ -23,6 +23,7 @@ class PhoenixClient {
* @param {string} [options.server] - Phoenix server URL (default: PHOENIX_SERVER env or http://127.0.0.1:9090)
* @param {string} [options.token] - Bearer token (default: PHOENIX_TOKEN env)
* @param {number} [options.timeout] - Request timeout in ms (default: 10000)
+ * @param {string} [options.sealKeyPath] - Path to seal private key file (enables sealed mode)
*/
constructor(options = {}) {
this.server = (
@@ -37,6 +38,55 @@ class PhoenixClient {
"";
this.timeout = options.timeout || 10000;
+ this._sealPriv = null;
+ this._sealPubB64 = null;
+ this._nacl = null;
+
+ const keyPath =
+ options.sealKeyPath ||
+ (typeof process !== "undefined" && process.env?.PHOENIX_SEAL_KEY);
+ if (keyPath) {
+ // Defer to setSealKey — errors surface on first resolve call
+ this._pendingSealKey = this.setSealKey(keyPath).catch((err) => {
+ this._sealKeyError = err;
+ });
+ }
+ }
+
+ /**
+ * Load a seal private key, enabling sealed mode.
+ * Requires the `tweetnacl` package.
+ * @param {string} path - Path to the seal private key file
+ */
+ async setSealKey(path) {
+ let raw;
+ try {
+ const fs = await import("node:fs/promises");
+ raw = (await fs.readFile(path, "utf8")).trim();
+ } catch (err) {
+ throw new PhoenixError(`reading seal key: ${err.message}`);
+ }
+ const privBytes = Buffer.from(raw, "base64");
+ if (privBytes.length !== 32) {
+ throw new PhoenixError(
+ `seal key must be 32 bytes, got ${privBytes.length}`
+ );
+ }
+
+ try {
+ this._nacl = (await import("tweetnacl")).default;
+ } catch {
+ throw new PhoenixError(
+ "tweetnacl is required for sealed mode: npm install tweetnacl"
+ );
+ }
+
+ this._sealPriv = privBytes;
+ const keyPair = this._nacl.box.keyPair.fromSecretKey(
+ new Uint8Array(privBytes)
+ );
+ this._sealPubB64 = Buffer.from(keyPair.publicKey).toString("base64");
+ this._sealKeyError = null;
}
/**
@@ -65,6 +115,7 @@ class PhoenixClient {
/**
* Resolve multiple phoenix:// references in one API call.
+ * When sealed mode is enabled, responses are auto-decrypted transparently.
* @param {string[]} refs - List of phoenix:// references
* @returns {Promise<{values: Object, errors?: Object}>}
*/
@@ -72,7 +123,66 @@ class PhoenixClient {
if (!refs || refs.length === 0) {
throw new PhoenixError("refs must not be empty");
}
- return this._request("POST", "/v1/resolve", { refs });
+ if (this._pendingSealKey) {
+ await this._pendingSealKey;
+ this._pendingSealKey = null;
+ }
+ if (this._sealKeyError) {
+ throw this._sealKeyError;
+ }
+ const result = await this._request("POST", "/v1/resolve", { refs });
+
+ if (this._sealPriv && result.sealed_values) {
+ result.values = {};
+ for (const [ref, env] of Object.entries(result.sealed_values)) {
+ if (env.ref !== ref) {
+ throw new PhoenixError(
+ `sealed envelope ref mismatch: map key "${ref}", envelope "${env.ref}"`
+ );
+ }
+ result.values[ref] = this._unsealEnvelope(env);
+ }
+ delete result.sealed_values;
+ }
+
+ return result;
+ }
+
+ /**
+ * Decrypt a sealed envelope using the loaded private key.
+ * @param {object} env - Sealed envelope object
+ * @returns {string} Decrypted value
+ */
+ _unsealEnvelope(env) {
+ if (!this._nacl || !this._sealPriv) {
+ throw new PhoenixError("sealed mode not configured");
+ }
+ if (env.version !== 1) {
+ throw new PhoenixError(`unsupported seal version: ${env.version}`);
+ }
+ if (env.algorithm !== "x25519-xsalsa20-poly1305") {
+ throw new PhoenixError(`unsupported seal algorithm: ${env.algorithm}`);
+ }
+
+ const ephPub = new Uint8Array(Buffer.from(env.ephemeral_key, "base64"));
+ const nonce = new Uint8Array(Buffer.from(env.nonce, "base64"));
+ const ciphertext = new Uint8Array(Buffer.from(env.ciphertext, "base64"));
+ const privKey = new Uint8Array(this._sealPriv);
+
+ const plaintext = this._nacl.box.open(ciphertext, nonce, ephPub, privKey);
+ if (!plaintext) {
+ throw new PhoenixError("decryption failed");
+ }
+
+ const payload = JSON.parse(new TextDecoder().decode(plaintext));
+ if (payload.path !== env.path) {
+ throw new PhoenixError("path mismatch in sealed envelope");
+ }
+ if (payload.ref !== env.ref) {
+ throw new PhoenixError("ref mismatch in sealed envelope");
+ }
+
+ return payload.value;
}
/**
@@ -88,6 +198,9 @@ class PhoenixClient {
if (this.token) {
headers["Authorization"] = `Bearer ${this.token}`;
}
+ if (this._sealPubB64) {
+ headers["X-Phoenix-Seal-Key"] = this._sealPubB64;
+ }
const options = {
method,
diff --git a/sdk/typescript/src/client.test.js b/sdk/typescript/src/client.test.js
index 6b7f1ca..81fcfb5 100644
--- a/sdk/typescript/src/client.test.js
+++ b/sdk/typescript/src/client.test.js
@@ -134,3 +134,251 @@ describe("PhoenixClient", () => {
assert.equal(c.server, "http://127.0.0.1:9090");
});
});
+
+describe("PhoenixClient sealed mode", () => {
+ const nacl = require("tweetnacl");
+ const fs = require("node:fs");
+ const os = require("node:os");
+ const path = require("node:path");
+
+ function generateKeypair() {
+ return nacl.box.keyPair();
+ }
+
+ function sealValue(secretPath, ref, value, recipientPub) {
+ const eph = nacl.box.keyPair();
+ const nonce = nacl.randomBytes(24);
+ const payload = Buffer.from(
+ JSON.stringify({
+ path: secretPath,
+ ref,
+ value,
+ issued_at: new Date().toISOString(),
+ })
+ );
+ const ciphertext = nacl.box(payload, nonce, recipientPub, eph.secretKey);
+ return {
+ version: 1,
+ algorithm: "x25519-xsalsa20-poly1305",
+ path: secretPath,
+ ref,
+ ephemeral_key: Buffer.from(eph.publicKey).toString("base64"),
+ nonce: Buffer.from(nonce).toString("base64"),
+ ciphertext: Buffer.from(ciphertext).toString("base64"),
+ };
+ }
+
+ function writeKeyFile(privKey) {
+ const tmp = path.join(os.tmpdir(), `seal-test-${Date.now()}.key`);
+ fs.writeFileSync(tmp, Buffer.from(privKey).toString("base64"), {
+ mode: 0o600,
+ });
+ return tmp;
+ }
+
+ it("sealed resolve decrypts transparently", async () => {
+ const kp = generateKeypair();
+ const keyPath = writeKeyFile(kp.secretKey);
+
+ const { url, close } = await createTestServer((req, res) => {
+ let body = "";
+ req.on("data", (d) => (body += d));
+ req.on("end", () => {
+ const parsed = JSON.parse(body);
+ const sealHeader = req.headers["x-phoenix-seal-key"];
+ assert.ok(sealHeader, "seal key header should be present");
+
+ const pub = new Uint8Array(Buffer.from(sealHeader, "base64"));
+ const ref = parsed.refs[0];
+ const secretPath = ref.replace("phoenix://", "");
+ const env = sealValue(secretPath, ref, "sealed-secret", pub);
+
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ sealed_values: { [ref]: env } }));
+ });
+ });
+
+ try {
+ const c = new PhoenixClient({ server: url, token: "test" });
+ await c.setSealKey(keyPath);
+ const val = await c.resolve("phoenix://ns/key");
+ assert.equal(val, "sealed-secret");
+ } finally {
+ close();
+ fs.unlinkSync(keyPath);
+ }
+ });
+
+ it("sealed batch resolve decrypts multiple values", async () => {
+ const kp = generateKeypair();
+ const keyPath = writeKeyFile(kp.secretKey);
+
+ const { url, close } = await createTestServer((req, res) => {
+ let body = "";
+ req.on("data", (d) => (body += d));
+ req.on("end", () => {
+ const parsed = JSON.parse(body);
+ const pub = new Uint8Array(
+ Buffer.from(req.headers["x-phoenix-seal-key"], "base64")
+ );
+ const sealed_values = {};
+ for (const ref of parsed.refs) {
+ const p = ref.replace("phoenix://", "");
+ sealed_values[ref] = sealValue(p, ref, `val-${p}`, pub);
+ }
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ sealed_values }));
+ });
+ });
+
+ try {
+ const c = new PhoenixClient({ server: url, token: "test" });
+ await c.setSealKey(keyPath);
+ const result = await c.resolveBatch([
+ "phoenix://ns/k1",
+ "phoenix://ns/k2",
+ ]);
+ assert.equal(result.values["phoenix://ns/k1"], "val-ns/k1");
+ assert.equal(result.values["phoenix://ns/k2"], "val-ns/k2");
+ } finally {
+ close();
+ fs.unlinkSync(keyPath);
+ }
+ });
+
+ it("plaintext resolve works without seal key", async () => {
+ const { url, close } = await createTestServer((req, res) => {
+ assert.equal(
+ req.headers["x-phoenix-seal-key"],
+ undefined,
+ "no seal header without key"
+ );
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ values: { "phoenix://ns/s": "plain" } }));
+ });
+
+ try {
+ const c = new PhoenixClient({ server: url, token: "test" });
+ const val = await c.resolve("phoenix://ns/s");
+ assert.equal(val, "plain");
+ } finally {
+ close();
+ }
+ });
+
+ it("unseal rejects bad version", async () => {
+ const kp = generateKeypair();
+ const keyPath = writeKeyFile(kp.secretKey);
+
+ try {
+ const c = new PhoenixClient({ server: "http://localhost:1", token: "t" });
+ await c.setSealKey(keyPath);
+ assert.throws(
+ () =>
+ c._unsealEnvelope({
+ version: 99,
+ algorithm: "x25519-xsalsa20-poly1305",
+ }),
+ { name: "PhoenixError", message: /version/ }
+ );
+ } finally {
+ fs.unlinkSync(keyPath);
+ }
+ });
+
+ it("unseal rejects bad algorithm", async () => {
+ const kp = generateKeypair();
+ const keyPath = writeKeyFile(kp.secretKey);
+
+ try {
+ const c = new PhoenixClient({ server: "http://localhost:1", token: "t" });
+ await c.setSealKey(keyPath);
+ assert.throws(
+ () => c._unsealEnvelope({ version: 1, algorithm: "aes-gcm" }),
+ { name: "PhoenixError", message: /algorithm/ }
+ );
+ } finally {
+ fs.unlinkSync(keyPath);
+ }
+ });
+
+ it("setSealKey rejects wrong-length key", async () => {
+ const tmp = path.join(os.tmpdir(), `seal-bad-${Date.now()}.key`);
+ fs.writeFileSync(tmp, Buffer.from("short").toString("base64"), {
+ mode: 0o600,
+ });
+
+ try {
+ const c = new PhoenixClient({ server: "http://localhost:1" });
+ await assert.rejects(() => c.setSealKey(tmp), {
+ name: "PhoenixError",
+ message: /32 bytes/,
+ });
+ } finally {
+ fs.unlinkSync(tmp);
+ }
+ });
+
+ it("setSealKey rejects missing file", async () => {
+ const c = new PhoenixClient({ server: "http://localhost:1" });
+ await assert.rejects(() => c.setSealKey("/nonexistent/path"));
+ });
+
+ it("rejects ref-swap attack in batch resolve", async () => {
+ const kp = generateKeypair();
+ const keyPath = writeKeyFile(kp.secretKey);
+
+ const { url, close } = await createTestServer((req, res) => {
+ let body = "";
+ req.on("data", (d) => (body += d));
+ req.on("end", () => {
+ const pub = new Uint8Array(
+ Buffer.from(req.headers["x-phoenix-seal-key"], "base64")
+ );
+ // Create envelopes with correct refs but swap the map keys
+ const envA = sealValue("ns/a", "phoenix://ns/a", "A", pub);
+ const envB = sealValue("ns/b", "phoenix://ns/b", "B", pub);
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(
+ JSON.stringify({
+ sealed_values: {
+ "phoenix://ns/a": envB, // swapped!
+ "phoenix://ns/b": envA, // swapped!
+ },
+ })
+ );
+ });
+ });
+
+ try {
+ const c = new PhoenixClient({ server: url, token: "test" });
+ await c.setSealKey(keyPath);
+ await assert.rejects(
+ () =>
+ c.resolveBatch(["phoenix://ns/a", "phoenix://ns/b"]),
+ { name: "PhoenixError", message: /ref mismatch/ }
+ );
+ } finally {
+ close();
+ fs.unlinkSync(keyPath);
+ }
+ });
+
+ it("bad PHOENIX_SEAL_KEY in constructor does not crash process", async () => {
+ const orig = process.env.PHOENIX_SEAL_KEY;
+ process.env.PHOENIX_SEAL_KEY = "/no/such/file";
+ try {
+ const c = new PhoenixClient({ server: "http://localhost:1" });
+ // Constructor should not throw — error deferred to first resolve
+ await new Promise((r) => setTimeout(r, 20));
+ // Attempting resolve should surface the error
+ await assert.rejects(() => c.resolve("phoenix://ns/x"));
+ } finally {
+ if (orig !== undefined) {
+ process.env.PHOENIX_SEAL_KEY = orig;
+ } else {
+ delete process.env.PHOENIX_SEAL_KEY;
+ }
+ }
+ });
+});