Credential Encryption
All credentials in Crewship are encrypted at rest using AES-256-GCM. The implementation ininternal/encryption/encryption.go is designed for cross-compatibility between Go and TypeScript.
Encryption Scheme
Wire Format
Encrypted values are stored as versioned base64 strings:Byte Layout
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.
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 bootcrewship 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
WARNlog fires on every boot while the key is auto-generated/colocated. GET /api/v1/admin/health(CLI:crewship system health) reportsencryption_key_source:"external"(operator-injected env var),"generated"(auto-bootstrapped, colocated), or"unknown".
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 noENCRYPTION_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 returns500. 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: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.
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 thevN: 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 usableENCRYPTION_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:
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):
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.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.
Legacy Format Support
The decryptor accepts both:- Versioned:
v1:base64data(current format) - Legacy:
base64data(no version prefix, treated as v1)
Cross-Language Compatibility
The encryption format is shared between:- Go:
internal/encryption/encryption.go - TypeScript:
lib/encryption.ts
- IV size: 16 bytes
- Byte order: IV || AuthTag || Ciphertext
- Base64 encoding: standard or raw
- 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.