Scope: Cryptographic primitives used by LQ.AI, key lifecycle, and known limitations. Intended for operators evaluating the system for procurement, and for security reviewers tracing a specific control. Anchored to the M1 implementation — not generic AppSec text.
| Purpose | Algorithm | Library | Reference |
|---|---|---|---|
| Session access tokens | JWT HS256 | PyJWT (pyjwt[crypto]) |
api/app/security/jwt.py:43 (_JWT_ALGORITHM = "HS256"); minted at :99, verified at :117 |
| MFA challenge tokens | JWT HS256 | PyJWT |
api/app/security/jwt.py:161 (minted), :168 (verified) |
| Refresh tokens (plaintext) | CSPRNG, 32 bytes urlsafe-base64 | Python secrets |
api/app/security/jwt.py:219 (secrets.token_urlsafe(32)) |
| Refresh tokens (at rest) | bcrypt | bcrypt (direct, not via passlib) |
api/app/security/jwt.py:223-228 |
| User passwords (at rest) | bcrypt, cost factor 12 (configurable) | bcrypt |
api/app/security/passwords.py:25-40; cost factor at api/app/config.py:207 |
| MFA recovery codes (at rest) | bcrypt | bcrypt (via app.security.passwords) |
api/app/security/totp.py:117-122 |
| TOTP shared secret | RFC 6238 (base32, 30 s step, 6 digits) | pyotp |
api/app/security/totp.py:64-71 |
| First-run admin password | CSPRNG, 24 chars from [A-Za-z0-9] (~143 bits) |
Python secrets |
api/app/admin_bootstrap.py:52-58 (secrets.choice) |
| MFA recovery-code generation | CSPRNG, 48-bit hex tokens | Python secrets |
api/app/security/totp.py:117-120 (secrets.token_hex) |
| Provider API keys (at rest) | Fernet (AES-128-CBC + HMAC-SHA256, RFC-compliant authenticated encryption) | cryptography |
gateway/app/secrets.py:33, encrypted-keys.md, ADR 0011 §Encrypted-at-rest provider keys |
| In-transit between services | TLS 1.2+ | Operator-deployed via ingress | Helm chart Ingress (.Values.ingress.tls) |
No application-layer database-column encryption. The PostgreSQL pgcrypto extension is used only for gen_random_uuid() server defaults (primary keys), not for encrypted columns; all at-rest encryption decisions are made in application code.
- Generation: operator-supplied at deployment time. Recommended:
openssl rand -hex 32(256 bits). The Pydantic default ("dev-jwt-secret-change-me"atapi/app/config.py:149) is intentionally obvious so it trips review. - Storage: Kubernetes Secret
lq-ai-authkeyjwt-secret(deploy/helm/lq-ai/values.yaml:63-64; referenced fromdeploy/helm/lq-ai/templates/deployment-api.yaml:57). For Docker Compose deployments:.enventryJWT_SECRET. - Rotation: restart api with a new
JWT_SECRET. Existing access and MFA tokens fail signature verification; refresh tokens (opaque random + bcrypt hash, not JWT) survive rotation but the user will need to re-authenticate when their access token expires. No graceful overlap window in M1. - Disclosure impact: an attacker with
JWT_SECRETcan forge any user's access token (includingis_admin=true). Treat as "rotate immediately + accept that all sessions invalidate."
- Generation: operator-controlled via
python -m gateway.cli generate-master-key. See encrypted-keys.md §One-time bootstrap. - Storage: operator's secrets vault. Gateway reads the plaintext master key from
LQ_AI_GATEWAY_MASTER_KEYat process start (gateway/app/secrets.py:45); never on disk after the encryption helper exits. - Rotation: re-encrypt every provider key under the new master before restarting the gateway with the new value. Fernet has no in-band rotation primitive; the operator runs the encryption CLI once per provider with the new master, swaps the
api_key_encryptedtokens ingateway.yaml, then restarts. - Disclosure impact: an attacker with the master key can decrypt every
api_key_encryptedingateway.yaml. Treat as "rotate master + re-encrypt all provider keys."
- Generation: operator-supplied at deployment time. Recommended:
openssl rand -hex 32(deploy/helm/lq-ai/values-example.yaml:10). - Storage: Kubernetes Secret
lq-ai-authkeygateway-key(deploy/helm/lq-ai/values.yaml:65; referenced fromdeploy/helm/lq-ai/templates/deployment-gateway.yaml:29-31anddeploy/helm/lq-ai/templates/deployment-api.yaml:52). For Docker Compose:.envLQ_AI_GATEWAY_KEY. - Rotation: the gateway picks up new admin-managed configuration via the hot-reload path (ADR 0010); the shared-secret env var itself requires a coordinated restart of both api and gateway with the new value.
- Disclosure impact: an attacker with this key can call the gateway directly, bypassing api-level audit logging.
Two paths, per ADR 0011 §Encrypted-at-rest provider keys:
api_key_env— operator passes the plaintext key as an env var. Plaintext exists in the gateway process environment for the lifetime of the process and is visible to anything reading/proc/<pid>/environ.api_key_encrypted— operator runspython -m gateway.cli encrypt-keyto produce a Fernet ciphertext (gateway/app/cli.py); gateway decrypts in memory at adapter-build time. Plaintext never on disk after the encryption helper exits. See encrypted-keys.md for the full operator workflow including rotation and recovery.
- Generation:
pyotp.random_base32()at MFA enrollment. - Storage: plaintext on
users.totp_secret. The operator-of-self model gives the user no recovery path from a lost secret other than disabling MFA; storing the secret plaintext is a deliberate trade against re-enrollment churn. Database-leak exposure is mitigated by the same DB-access controls protecting password hashes. - Rotation: user-initiated via re-enrolling MFA (issues a fresh secret and a fresh recovery-code set).
- HS256 (symmetric) instead of RS256/ES256. M1 uses HS256 because a single backend service signs tokens for itself — there is no third party that needs to verify without holding the secret. Asymmetric signing would add operational complexity (key-pair management, public-key distribution) without changing the threat model in our deployment topology. If a future milestone introduces a second verifier (e.g., a separate audit-log signer), switching to RS256/ES256 is a localized change in
api/app/security/jwt.py(the_JWT_ALGORITHMconstant plus the secret-loading path). - No session-overlap during
JWT_SECRETrotation. Rotation invalidates every outstanding access and MFA token. Acceptable for M1's deployment scale; a graceful overlap window (dual-key verification during a rollover) is a future enhancement if operator deployments grow large enough that simultaneous re-authentication becomes operationally painful. - Fernet uses AES-128-CBC + HMAC-SHA256, not AEAD-GCM. Fernet is a documented profile that does provide authenticated encryption via HMAC, but it is not NIST-AEAD. Operators preferring NIST-standardized AEAD-GCM can swap in libsodium-based encryption by replacing
gateway/app/secrets.py; the chart, CLI, andgateway.yamlschema continue to work as long as the wire format is documented in the replacement. - TLS termination is operator-managed. LQ.AI does not terminate TLS in any container; ingress or an operator-supplied reverse proxy handles certs. This is by design (cert management is operator policy, not application policy) but means the application has no telemetry on TLS-handshake-level threats. The Helm chart's
.Values.ingress.tlsblock (deploy/helm/lq-ai/templates/ingress.yaml:17-19) is the operator's hook. - No HSM / KMS integration in M1. The master key for Fernet-wrapped provider keys is an env-var-held secret. Operators with HSM/KMS requirements can fork
gateway/app/secrets.pyto source the master key from cloud KMS at process start; a built-in adapter (AWS KMS, GCP KMS, Vault Transit) is a candidate for a future milestone. - bcrypt is not memory-hard. bcrypt at cost factor 12 is the OWASP-recommended floor as of 2024, and the dominant offline-cracking constraint here is the entropy of refresh tokens (32 bytes / 256 bits) and the generated admin password (~143 bits) rather than the hash function. Operators handling high-value user-chosen passwords against a determined offline attacker can raise
BCRYPT_ROUNDS(api/app/config.py:207) or, as a future enhancement, the project may migrate to argon2id — both are localized changes inapi/app/security/passwords.py.