Skip to main content

Credential Encryption

All credentials in Crewship are encrypted at rest using AES-256-GCM. The implementation in internal/encryption/encryption.go is designed for cross-compatibility between Go and TypeScript.

Encryption Scheme

The IV/nonce is 16 bytes, not the standard 12 bytes. This is set via cipher.NewGCMWithNonceSize(block, 16) for Go/TypeScript compatibility. Changing this breaks all stored credentials.

Wire Format

Encrypted values are stored as versioned base64 strings:

Byte Layout

This is a custom byte order (IV || AuthTag || Ciphertext), not the standard Go GCM output format (Ciphertext || AuthTag). The reordering is necessary for compatibility with the TypeScript implementation in lib/encryption.ts.
The byte layout is IV(16) || AuthTag(16) || Ciphertext. This differs from Go’s default GCM Seal output which produces Ciphertext || AuthTag. The encryption code explicitly splits and reorders these components. Changing this layout breaks all stored credentials and cross-language compatibility.

Encryption Flow

Decryption Flow

Key Versioning

Envelopes are prefixed with a version identifier (e.g., v1:) to support key rotation. The version NEW envelopes are minted with defaults to v1 and is switched with the CREWSHIP_ENCRYPTION_KEY_VERSION environment variable during a rotation. Decryption always honours the per-envelope prefix, so any generation whose key is still in the environment remains readable.

Key Resolution

On decrypt, if the version-specific env var is not set, resolution falls back to ENCRYPTION_KEY (legacy compatibility). On encrypt, the resolution is strict: minting vN envelopes requires ENCRYPTION_KEY_VN to be present — a silent fallback would stamp envelopes with a version whose key they were not encrypted with. crewship start validates the active version’s key at boot and refuses to start on a misconfigured rotation.

Key Source Visibility

On first boot crewship start auto-generates ENCRYPTION_KEY and persists it to <dataDir>/secrets.env (mode 0600) — which on default installs is the same volume as the SQLite database. A copied disk or volume backup therefore carries both the ciphertext and the key. Crewship surfaces this:
  • A startup WARN log fires on every boot while the key is auto-generated/colocated.
  • GET /api/v1/admin/health (CLI: crewship system health) reports encryption_key_source: "external" (operator-injected env var), "generated" (auto-bootstrapped, colocated), or "unknown".
For stronger at-rest protection, supply ENCRYPTION_KEY from an external secret store (systemd EnvironmentFile=, Kubernetes secret, Vault) and remove the entry from secrets.env.

No key configured (fail-closed)

Secret writes fail closed. If no usable encryption key resolves for the active key version, Crewship refuses to store the secret rather than silently writing it to the database in plaintext. This closes a compatibility shim that shipped with the webhook-secret work (#1072/#1029) and was never given a sunset: before this change, a deployment with no ENCRYPTION_KEY stored webhook signing secrets as plaintext and the only trace was a log line nobody was reading.

What an operator sees

The write fails and the API returns 500. For the webhook-secret endpoints (agent webhook-secret rotate, pipeline-webhook create) the response body itself is actionable — it names ENCRYPTION_KEY and the opt-out flag — and the server log carries the full reason, naming the env var that is missing or malformed:

How to fix it

Generate a key and put it in the environment, then restart:
On a normal install you should never hit this: crewship start bootstraps ENCRYPTION_KEY on first boot (persisting it to <dataDir>/secrets.env) and then calls VerifyCurrentKey() — it refuses to start if the active key version’s env var is missing or malformed. So a key-less deployment is told at boot, not at first secret write. The fail-closed encrypt path is the backstop for embedded/alternate entry points that skip that bootstrap, and for a mid-rotation CREWSHIP_ENCRYPTION_KEY_VERSION pointing at a key that has not been supplied yet.
Escape hatch: CREWSHIP_ALLOW_PLAINTEXT_SECRETS. Setting it to true (also accepted: 1, yes, on — anything else means false) restores the old behaviour: with no key, secrets are written to the database in plaintext.Every such write emits a WARN, deliberately not deduplicated, so the count of unprotected secrets is auditable:
The secret value itself is never logged. Treat this flag as a short-lived transition aid, not a configuration: anything written while it is on stays plaintext in the database until you configure a key and run crewship admin reencrypt.

Reading already-stored plaintext

Fail-closed applies to writes only. Values already stored in plaintext by an older build (or under the opt-out) continue to be read back unchanged, with or without a key present and regardless of the flag — the read path discriminates on the vN: envelope prefix. Turning fail-closed on therefore cannot brick an existing install’s stored secrets.

The migration backfill waits for a key

Upgrading to the release that introduced webhook-secret encryption runs a one-time migration (v140) that encrypts any webhook/signing secrets previously stored in plaintext. If no usable ENCRYPTION_KEY is configured when that migration runs, it skips instead of failing — a migration must never break boot — and logs a WARN that the secrets are left plaintext at rest. The backfill is effectively deferred: the migration does not run again later, so after configuring a key, run:
to perform the backfill the skip deferred. Newly written secrets are protected by the fail-closed write path either way.

Rotating the Master Key

crewship admin reencrypt re-encrypts every stored envelope to the current key version, so the old key can actually be retired instead of living in the environment forever. Runbook (rotating from v1 to v2):
The operation is idempotent — envelopes already at the current version are skipped — so an interrupted run can simply be repeated. Undecryptable values (no configured key opens them) are counted under failed and left untouched; the CLI exits non-zero in that case so a scripted rotation never retires the old key on a false success. The walk covers every envelope-bearing column: credentials.encrypted_value, credentials.encrypted_refresh_token, credentials.oauth_client_secret_enc, credentials.oauth_refresh_token_enc, credential_rotations.old_value, notification_channels.secret_enc, composio_settings.encrypted_api_key, oauth_states.code_verifier, escalations.resolution (credential-type escalations only), and the two webhook-secret columns agents.webhook_secret and pipeline_webhooks.signing_secret.
Webhook secrets are encrypted at rest, fail-closed. agents.webhook_secret and pipeline_webhooks.signing_secret are encrypted whenever a usable key resolves; with no key the write is rejected rather than downgraded to plaintext (see No key configured below). Rows written in plaintext by older builds keep working — they are read back as-is. During master-key rotation such a bare (non-enveloped) webhook value is reported as skipped, not failed, so the failed=0 ⇒ retire the old key criterion stays honest. Migration v140 backfills existing plaintext webhook secrets to envelopes when a key is present.
One known edge case: databases created in the brief pre-release window before encrypt-on-resolve landed (March 2026) may hold credential-type escalation resolutions stored as plaintext. Those rows always count under failed (they are left untouched — no corruption), so a failed: 0 state is unreachable on such a database without clearing the affected historical escalations.resolution rows manually.
Backup bundles created before the rotation contain credential envelopes under the old key. Restoring one requires the old key to still be resolvable (keep it archived offline), or take a fresh backup after the re-encryption run.The CLI-side backup keyring (~/.crewship/backup-keyring.enc) is a separate rotation domain: it is encrypted with the CLI host’s own ENCRYPTION_KEY, not the server’s, so the server-side run never touches it. If you rotate the CLI host’s key, re-store the affected passphrases (crewship backup create --use-keyring re-writes the entry) or keep that host’s old key resolvable.

Legacy Format Support

The decryptor accepts both:
  • Versioned: v1:base64data (current format)
  • Legacy: base64data (no version prefix, treated as v1)
Base64 decoding tries standard encoding first, then falls back to raw (no padding) for TypeScript compatibility.

Cross-Language Compatibility

The encryption format is shared between:
  • Go: internal/encryption/encryption.go
  • TypeScript: lib/encryption.ts
Both implementations must agree on:
  1. IV size: 16 bytes
  2. Byte order: IV || AuthTag || Ciphertext
  3. Base64 encoding: standard or raw
  4. Key derivation: direct hex decode (no KDF)

Security Properties

Generating an Encryption Key

The encryption key must be exactly 64 hex characters (32 bytes). A shorter or longer key will cause hex.DecodeString to fail or produce the wrong key length for AES-256.

Credential Lifecycle

Credentials are transmitted to the sidecar via stdin JSON — not environment variables. This prevents credential leakage through /proc/environ or process listing tools.