Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,22 @@ JWT_REFRESH_EXPIRES_IN=7d
# MUST be changed from the default in production.
DOWNLOAD_SECRET=change-me-in-production

# ── Field encryption (reversible secrets at rest) ─────────────────────────────
#
# Reversible secrets that cannot be hashed (e.g. webhook HMAC signing secrets)
# are encrypted at rest with AES-256-GCM. Required in any environment that reads
# or writes those columns. See docs/field-encryption.md for the rotation runbook.
#
# Single-key shorthand: a base64-encoded 32-byte key (used under key id "default").
# Generate with: openssl rand -base64 32
FIELD_ENCRYPTION_KEY=GENERATE_WITH_openssl_rand_base64_32
#
# Multi-key form for rotation: a JSON array of { kid, key } objects. The FIRST
# entry is the active key used to encrypt new data; the rest are retained so
# data written under a retired key id still decrypts. If set, this takes
# precedence over FIELD_ENCRYPTION_KEY.
# FIELD_ENCRYPTION_KEYS=[{"kid":"2026-06","key":"BASE64_32_BYTES"},{"kid":"2026-01","key":"OLD_BASE64_32_BYTES"}]

# ── Soroban ──────────────────────────────────────────────────────────────────────

# Soroban contract ID (56-char base32 starting with C). Required for submit mode.
Expand Down
130 changes: 130 additions & 0 deletions docs/field-encryption.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# Field Encryption at Rest

Some secrets stored by Disciplr cannot be hashed because they must be recovered
in plaintext at use time. The clearest example is a **webhook HMAC signing
secret**: to sign every outbound delivery the server must present the original
secret, so a one-way hash (as used for API keys) is not an option. Those columns
are instead **encrypted at rest** with authenticated encryption.

This document describes the scheme and the key-rotation runbook.

## What is encrypted

| Column | Reversible? | Storage |
| --------------------------------------- | ----------- | ---------------- |
| `webhook_subscribers.secret` | yes | AES-256-GCM |
| `webhook_subscribers.previous_secret` | yes | AES-256-GCM |
| `api_keys.key_hash` | no | Argon2id + SHA-256 (unchanged) |

API keys remain hashed (irreversible) — they are verified, never re-presented,
so they do **not** use this mechanism.

## Scheme

Encryption is implemented in [`src/lib/encryption.ts`](../src/lib/encryption.ts)
using **AES-256-GCM** (authenticated encryption). Each value is stored as a
single self-describing string:

```
v1:<kid>:<iv_b64>:<authTag_b64>:<ciphertext_b64>
```

- `v1` — scheme version, so the format can evolve.
- `<kid>` — id of the key that produced this ciphertext. Resolved back to key
material on decryption, which is what makes rotation possible without bulk
re-encryption.
- `<iv_b64>` — 12-byte random nonce (a fresh IV per encryption).
- `<authTag_b64>` — 16-byte GCM authentication tag.
- `<ciphertext_b64>` — the ciphertext.

Properties:

- **Confidentiality** — a database dump alone never exposes the plaintext.
- **Integrity / tamper detection** — any modification to the stored value fails
the GCM authentication check on decrypt.
- **Fail closed** — decryption throws `DecryptionError` on a wrong key, an
unknown key id, malformed input, or a failed auth check. It **never** returns
the ciphertext or a partial result, so corrupt or forged data can never be
silently mistaken for plaintext.

## Key configuration

Keys are supplied via environment variables (see
[`src/config/env.ts`](../src/config/env.ts)). Two forms, in priority order:

1. **`FIELD_ENCRYPTION_KEYS`** — a JSON array of `{ kid, key }` objects, where
`key` is a base64-encoded **32-byte** (AES-256) key. The **first** entry is
the active key used to encrypt new data; the remaining entries are retained
only so ciphertext written under an older `kid` still decrypts.

```
FIELD_ENCRYPTION_KEYS=[{"kid":"2026-06","key":"<base64-32-bytes>"},{"kid":"2026-01","key":"<old-base64-32-bytes>"}]
```

2. **`FIELD_ENCRYPTION_KEY`** — a single base64-encoded 32-byte key, treated as
the active key under the reserved key id `default`. Convenient for
development and single-key deployments. Ignored when `FIELD_ENCRYPTION_KEYS`
is set.

Generate a key:

```sh
openssl rand -base64 32
```

If neither variable is set, the encryption helpers throw `EncryptionKeyError` —
an encryption layer with no key is treated as a misconfiguration (fail closed),
never as a silent no-op.

## Key-rotation runbook

Rotation is **zero-downtime** and requires no bulk re-encryption, because every
ciphertext carries the `kid` of the key that produced it.

1. **Generate a new key.**

```sh
openssl rand -base64 32
```

2. **Prepend it to `FIELD_ENCRYPTION_KEYS`, keeping the old key.** The new key
becomes active (it is first); the old key is retained so existing rows keep
decrypting. Choose a `kid` that is stable and meaningful (e.g. a date).

```
FIELD_ENCRYPTION_KEYS=[
{"kid":"2026-09","key":"<new-base64-32-bytes>"},
{"kid":"2026-06","key":"<previous-base64-32-bytes>"}
]
```

> If you were previously using the single-key `FIELD_ENCRYPTION_KEY`, migrate
> to `FIELD_ENCRYPTION_KEYS` and include the old key under the `kid`
> `"default"`, since that is the id under which it encrypted existing rows.

3. **Deploy.** From this point, all newly written secrets are encrypted under
the new `kid`; old rows continue to decrypt under the retained key.

4. **(Optional) Re-encrypt existing rows.** Any write path that re-saves a
secret (creating, upserting, or rotating a webhook subscriber's secret)
automatically re-encrypts it under the active key. To migrate all rows
eagerly, read and re-save each affected row.

5. **Retire the old key.** Once you are confident no ciphertext is still tagged
with the old `kid` (after step 4, or after enough time that all such rows
have been rewritten), remove that entry from `FIELD_ENCRYPTION_KEYS` and
deploy again.

> **Do not remove a key while rows tagged with its `kid` still exist** —
> those rows would become permanently undecryptable and reads would throw
> `DecryptionError`.

## Operational notes

- **Never log key material.** Keys are provided only through environment
variables / your secrets manager and are never written to logs.
- **Back up keys** in your secrets manager. Losing a key whose `kid` still tags
live rows means losing those secrets irrecoverably.
- **Decryption failures are loud.** A `DecryptionError` on read indicates either
a removed/rotated-out key (`No field encryption key configured for key id …`)
or tampered data (`authentication failed`). Investigate rather than suppress.
7 changes: 7 additions & 0 deletions docs/security-ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ To ensure consistent builds and prevent malicious dependency injection during th
- **Secrets Management:** Sensitive tokens or keys are never logged in CI output.
- **Actionable Output:** Security reports are generated in JSON format for potential integration with external monitoring tools.

## Encryption at Rest

Reversible secrets that cannot be hashed (e.g. webhook HMAC signing secrets) are
encrypted at rest with AES-256-GCM and a rotatable, key-id-tagged key. See
[field-encryption.md](./field-encryption.md) for the scheme and the key-rotation
runbook.

## Best Practices for Developers

- Always run `npm audit` locally before committing dependency changes.
Expand Down
1 change: 1 addition & 0 deletions jest.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ module.exports = {
},
],
},
setupFiles: ["<rootDir>/jest.setup.cjs"],
testMatch: ["**/tests/**/*.test.ts", "**/src/tests/**/*.test.ts", "**/src/repositories/**/*.test.ts"],
moduleDirectories: ["node_modules", "<rootDir>/node_modules"],
clearMocks: true,
Expand Down
12 changes: 12 additions & 0 deletions jest.setup.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Global Jest setup: runs before each test file, before the test framework is
// installed. Used to seed environment defaults required by modules under test.

// Field encryption requires a key to be configured. Provide a deterministic,
// non-secret 32-byte (base64) key for the test environment so repositories that
// encrypt/decrypt reversible secrets work without each suite wiring its own key.
// Individual suites may override FIELD_ENCRYPTION_KEY / FIELD_ENCRYPTION_KEYS
// (e.g. to test rotation) and reset the cached env via _resetEnvForTesting().
if (!process.env.FIELD_ENCRYPTION_KEY && !process.env.FIELD_ENCRYPTION_KEYS) {
// 32 zero bytes, base64-encoded — test-only, never use in production.
process.env.FIELD_ENCRYPTION_KEY = Buffer.alloc(32, 0).toString('base64')
}
39 changes: 39 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,24 @@ export const envSchema = z
"REDIS_URL must be a valid Redis connection URL (starting with redis:// or rediss://)",
),

// ── Field encryption (envelope encryption for reversible secrets) ──
//
// Two ways to configure, in priority order:
// 1. FIELD_ENCRYPTION_KEYS – JSON array of { kid, key } objects, where
// `key` is a base64-encoded 32-byte (AES-256) key. The FIRST entry is
// the active key used to encrypt new data; the remaining entries are
// retained so ciphertext written under an older key id still decrypts
// after a rotation.
// 2. FIELD_ENCRYPTION_KEY – a single base64-encoded 32-byte key, treated
// as the active key under the reserved key id "default". Convenient for
// development / single-key deployments.
//
// Validation of the actual key material (length, base64) is performed in
// src/lib/encryption.ts so that decryption failures surface with precise,
// security-conscious error messages rather than as opaque Zod issues.
FIELD_ENCRYPTION_KEY: z.string().optional(),
FIELD_ENCRYPTION_KEYS: z.string().optional(),

// ── Auth / secrets ──────────────────────────────────────
JWT_SECRET: z
.string()
Expand Down Expand Up @@ -440,3 +458,24 @@ export function validateEnv(raw?: Record<string, string | undefined>): {
export function getJwtKeys(env: Env): JwtKey[] {
return (env as any).JWT_KEYS as JwtKey[];
}

/**
* Raw field-encryption configuration, read directly from the environment.
*
* Resolved independently of the full {@link initEnv} validation so the
* encryption helpers (src/lib/encryption.ts) can be used in isolation — e.g. in
* unit tests — without requiring a complete, valid application environment.
* The actual key material (base64, 32-byte length, key-id uniqueness) is
* validated in src/lib/encryption.ts, where decryption failures can surface
* precise, security-conscious errors.
*
* @param env Defaults to `process.env` — pass a custom record in tests.
*/
export function getFieldEncryptionConfig(
env: Record<string, string | undefined> = process.env,
): { key?: string; keys?: string } {
return {
key: env.FIELD_ENCRYPTION_KEY,
keys: env.FIELD_ENCRYPTION_KEYS,
};
}
Loading
Loading