> ## Documentation Index
> Fetch the complete documentation index at: https://docs.safeonlineexam.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Secret Management and File-Based Configuration Guide

> File-based secret alternatives, OAuth token keyring format, Compose secrets profile, and rotation procedures for Safe Online Exam production credentials.

Safe Online Exam supports file-based alternatives for every sensitive environment variable, allowing secrets to be supplied through Docker or Kubernetes secret mounts without embedding plaintext values in environment files or shell arguments. This page covers the `_FILE` pattern, the OAuth token encryption keyring format, the `compat` vs `enforce` mode, the Docker Compose secrets profile, and safe rotation procedures for each class of secret.

<Warning>
  Never place secret values in shell command arguments, terminal history, screenshots, log output, or version-controlled files. File-based secrets exist specifically to keep sensitive material out of the process environment and system logs. Protect every secrets directory and file with restrictive filesystem permissions before writing any content into it.
</Warning>

***

## File-Based Secret Alternatives

For each of the sensitive variables below, the application accepts a mutually exclusive `_FILE` alternative. Set either the direct variable **or** its file alternative — never both.

<Note>
  The application explicitly rejects a configuration where both a direct variable and its `_FILE` counterpart are present in the environment. It reports the conflict and exits without reading either value. Unreadable paths are also reported as errors without echoing any secret contents.
</Note>

| Direct variable                  | File alternative                      | When to use the file form                                        |
| -------------------------------- | ------------------------------------- | ---------------------------------------------------------------- |
| `DATABASE_PASSWORD`              | `DATABASE_PASSWORD_FILE`              | Any production deployment; always required in hardened runtimes. |
| `LTI_PRIVATE_KEY`                | `LTI_PRIVATE_KEY_FILE`                | Preferred for RSA JWK JSON — avoids multiline quoting issues.    |
| `CANVAS_API_CLIENT_SECRET`       | `CANVAS_API_CLIENT_SECRET_FILE`       | All production deployments.                                      |
| `SESSION_SECRET`                 | `SESSION_SECRET_FILE`                 | All production deployments.                                      |
| `STATE_ENCRYPTION_KEY`           | `STATE_ENCRYPTION_KEY_FILE`           | All production deployments.                                      |
| `OAUTH_TOKEN_ENCRYPTION_KEYRING` | `OAUTH_TOKEN_ENCRYPTION_KEYRING_FILE` | Always preferred; avoids shell escaping of JSON.                 |
| `SEB_QUIT_PASSWORD`              | `SEB_QUIT_PASSWORD_FILE`              | When a managed exit-password fallback is configured.             |

Files are read exactly once during configuration startup. Required-value validation treats a missing or empty file result as equivalent to a missing direct variable — startup fails before listening with a descriptive error message.

***

## OAuth Token Encryption Keyring

Safe Online Exam encrypts every stored Canvas OAuth access token and refresh token using AES-256-GCM. The keyring is a JSON object whose keys are string key IDs and whose values are 32-byte AES keys encoded as canonical base64url strings (no padding, URL-safe alphabet).

```json theme={null}
{
  "primary": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
  "retired-v1": "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
}
```

Each key value must decode to exactly 32 bytes. The application rejects any keyring entry with an invalid length, non-canonical encoding, or an illegal key ID character. Key IDs must match the pattern `[A-Za-z0-9][A-Za-z0-9._-]{0,63}`.

`OAUTH_TOKEN_ENCRYPTION_ACTIVE_KEY_ID` identifies which key ID in the keyring is used for new token writes. Every read operation accepts any key ID present in the keyring, so old records remain decryptable after a key rotation as long as the retired key is not yet removed.

### Generating a New AES Key

Use Node.js to generate a cryptographically random 32-byte base64url key:

```bash theme={null}
node -e "const {randomBytes}=require('crypto'); console.log(randomBytes(32).toString('base64url'));"
```

Write the output directly to a file rather than passing it through a shell variable:

```bash theme={null}
node -e "const {randomBytes}=require('crypto'); process.stdout.write(randomBytes(32).toString('base64url'));" \
  > ./secrets/oauth_token_encryption_keyring_new_key
```

### Keyring File Format

When using `OAUTH_TOKEN_ENCRYPTION_KEYRING_FILE`, write the full JSON keyring object to the file. The file must contain valid JSON with no BOM:

```json theme={null}
{"primary":"<32-byte-base64url-key>"}
```

After a key rotation with multiple keys present:

```json theme={null}
{"primary":"<new-32-byte-base64url-key>","v1":"<old-32-byte-base64url-key>"}
```

***

## Encryption Mode: enforce vs compat

`OAUTH_TOKEN_ENCRYPTION_MODE` controls how the application handles stored token records.

| Mode      | Behaviour                                                                                                                                                                                                                                 |
| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `enforce` | All new token writes are encrypted. Existing unencrypted records can still be read and are updated to the active key when explicitly rewritten via `npm run db:encrypt-oauth-tokens:built`. Use this mode for all production deployments. |
| `compat`  | New writes are stored without encryption (legacy plaintext format). This mode exists only as a temporary rollback preparation path and must not be used as a permanent configuration.                                                     |

Set `OAUTH_TOKEN_ENCRYPTION_MODE=enforce` in every production environment. The `compat` mode is documented only so that operators understand it is not a supported long-term state.

***

## The `db:encrypt-oauth-tokens` Maintenance Command

After rotating to a new active key ID, run the bulk rewrite command to update every stored record to the new key before removing the old key from the keyring:

```bash theme={null}
npm run db:encrypt-oauth-tokens:built
```

This command scans all rows in the `canvas_oauth_tokens` table and re-encrypts any record whose stored key ID does not match `OAUTH_TOKEN_ENCRYPTION_ACTIVE_KEY_ID`. It reports counts only and **fails closed** — if any row cannot be decrypted with the keys in the current keyring, the command exits with an error and leaves all remaining rows untouched.

Only remove a retired key from the keyring after two successive runs of this command both report zero updated rows. This guarantees that every stored record has been rewritten under the active key.

***

## Docker Compose Secrets Profile

The checked-in `compose.secrets.yaml` override implements the full file-based secrets pattern for Docker Compose deployments. Start it with `.env.compose.secrets.example` as the environment file.

The override does the following for every service that handles secrets (`postgres`, `migrate`, `app`, `cleanup`, and `encrypt-oauth-tokens`):

* Clears the corresponding direct secret variable to an empty value.
* Supplies the `_FILE` path pointing to `/run/secrets/<name>`.
* Mounts only the named secret files into each container via Docker's `secrets:` mechanism.

```yaml theme={null}
services:
  app:
    environment:
      DATABASE_PASSWORD:
      DATABASE_PASSWORD_FILE: /run/secrets/database_password
      LTI_PRIVATE_KEY:
      LTI_PRIVATE_KEY_FILE: /run/secrets/lti_private_key
      CANVAS_API_CLIENT_SECRET:
      CANVAS_API_CLIENT_SECRET_FILE: /run/secrets/canvas_api_client_secret
      SESSION_SECRET:
      SESSION_SECRET_FILE: /run/secrets/session_secret
      STATE_ENCRYPTION_KEY:
      STATE_ENCRYPTION_KEY_FILE: /run/secrets/state_encryption_key
      OAUTH_TOKEN_ENCRYPTION_KEYRING:
      OAUTH_TOKEN_ENCRYPTION_KEYRING_FILE: /run/secrets/oauth_token_encryption_keyring
      SEB_QUIT_PASSWORD:
      SEB_QUIT_PASSWORD_FILE: /run/secrets/seb_quit_password
    secrets:
      - database_password
      - lti_private_key
      - canvas_api_client_secret
      - session_secret
      - state_encryption_key
      - oauth_token_encryption_keyring
      - seb_quit_password
```

The PostgreSQL service also receives the database password through its native `POSTGRES_PASSWORD_FILE` input via the same mechanism.

### Directory Permissions

The `SECRETS_DIRECTORY` (default `./secrets`) is the host directory from which Compose reads each named file. On Linux hosts, set the containing directory to mode `0700` so that only the deploying user can list its contents:

```bash theme={null}
mkdir -p ./secrets
chmod 0700 ./secrets
```

Individual secret files within the directory can be mode `0644` — Docker mounts only the named files into each container, and the `0700` directory prevents enumeration from other accounts on the same host.

Required files in `SECRETS_DIRECTORY` for the secrets profile:

```text theme={null}
database_password
lti_private_key
canvas_api_client_secret
session_secret
state_encryption_key
oauth_token_encryption_keyring
seb_quit_password            (may be empty when no managed fallback is configured)
seb-config-encryption.crt.pem
```

***

## Secret Rotation Effects on Live Sessions

Each secret class has a distinct impact when rotated. Rotate one secret at a time and follow the create → deploy → smoke-test → disable-old sequence.

<Accordion title="SESSION_SECRET rotation">
  Rotating `SESSION_SECRET` immediately invalidates **all active user sessions**. Every user will be redirected to re-authenticate with Canvas on their next request. There is no grace period. Schedule rotation during a low-traffic window and communicate the expected re-authentication to users in advance.
</Accordion>

<Accordion title="STATE_ENCRYPTION_KEY rotation">
  Rotating `STATE_ENCRYPTION_KEY` invalidates all outstanding **opaque LTI and OAuth state tokens**. Any user in the middle of an LTI launch or Canvas OAuth authorisation flow at the moment of rotation will see an error and need to restart that flow. Sessions that have already completed are unaffected.
</Accordion>

<Accordion title="LTI_PRIVATE_KEY rotation">
  Rotating the LTI signing key requires coordination with Canvas. The new public key must appear in the JWKS endpoint at `${TOOL_URL}/lti/jwks` before Canvas can verify launches signed with it. After deploying the new key, re-register the tool in Canvas by fetching the configuration from `${TOOL_URL}/lti/config` so Canvas stores the updated JWKS reference.
</Accordion>

<Accordion title="OAUTH_TOKEN_ENCRYPTION_KEYRING rotation">
  To rotate an OAuth token encryption key safely:

  1. Add a new 32-byte base64url key under a new key ID to the keyring JSON.
  2. Update `OAUTH_TOKEN_ENCRYPTION_ACTIVE_KEY_ID` to the new key ID.
  3. Deploy the updated keyring and active key ID.
  4. Run `npm run db:encrypt-oauth-tokens:built` to rewrite all stored tokens to the new key.
  5. Run the command a second time and confirm zero updated rows.
  6. Only after both runs report zero updates, remove the old key from the keyring JSON and deploy again.

  The old key must remain in the keyring until all records have been rewritten. Removing it prematurely causes decryption failures for any record that has not yet been rewritten.
</Accordion>

<Accordion title="SEB certificate rotation">
  Rotating `SEB_CONFIG_ENCRYPTION_CERT_PEM` or `SEB_CONFIG_ENCRYPTION_CERT_PATH` requires distributing the matching new private identity to all managed SEB client devices before deploying the new certificate. Existing downloaded SEB configurations encrypted with the old certificate will no longer open after rotation. Issue fresh SEB configurations for all active assessments after deploying the new certificate.
</Accordion>
