Courier MFT

Security

Authentication, authorization, RBAC, encryption at rest, and audit logging.

This section consolidates all security concerns across the Courier platform: authentication, authorization, data protection at rest and in transit, secrets management, and hardening. Many security decisions are described in detail in their respective subsystem sections — this section provides the unified view and fills gaps.

12.1 Authentication

Courier uses a hybrid local-first + SSO-optional authentication model. Local username/password authentication works out of the box on first deployment, with no external identity provider required. Administrators can optionally configure enterprise SSO providers (OIDC, SAML) through the settings UI.

12.1.1 First-Run Setup

On first startup, a SetupGuardMiddleware blocks all API requests (except /api/v1/setup/*, /api/v1/auth/*, /health, /swagger) with 503 Service Unavailable until initial setup is completed. The frontend redirects to /setup, where the administrator creates the first admin account.

Setup flow:

  1. Frontend detects auth.setup_completed = false via GET /api/v1/setup/status and redirects to /setup
  2. Administrator enters username, display name, optional email, and password
  3. POST /api/v1/setup/initialize creates the admin user with Argon2id-hashed password and sets auth.setup_completed = true
  4. The setup guard cache is invalidated and subsequent requests proceed normally

12.1.2 Local Authentication

Password hashing: Argon2id with 64 MB memory, 4 iterations, 8-way parallelism, 16-byte random salt, 32-byte hash output. Stored as $argon2id$<base64-salt>$<base64-hash>. Verification uses CryptographicOperations.FixedTimeEquals to prevent timing attacks.

Login flow:

  1. POST /api/v1/auth/login with username and password
  2. Server validates credentials, checks account active status and lockout
  3. On success: returns JWT access token + opaque refresh token + user profile
  4. On failure: increments failed_login_count; locks account for configurable duration after threshold exceeded

JWT access tokens: HMAC-SHA256 signed, configurable lifetime (default 15 minutes). Claims: sub (user ID), role, name, email, jti.

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = "courier",
            ValidAudience = "courier-api",
            IssuerSigningKey = new SymmetricSecurityKey(keyBytes),
            ClockSkew = TimeSpan.FromSeconds(30),
        };
    });

Refresh tokens: 32 random bytes (base64-encoded), stored as SHA-256 hash in the refresh_tokens table. Configurable lifetime (default 7 days). Token rotation on every refresh — the old token is revoked and a new one issued. Revoked tokens cannot be reused.

Account lockout: After configurable failed attempts (default 5), the account is locked for a configurable duration (default 15 minutes). Successful login resets the failed counter and clears the lock.

12.1.3 SSO Authentication (Phase 2/3 — Future)

The auth_providers table and SSO fields on the users table are in place but not yet active. Planned phases:

  • Phase 2 — OIDC: Azure AD, Google, Okta via Microsoft.AspNetCore.Authentication.OpenIdConnect. After OIDC verification, the server issues the same JWT + refresh token pair as local auth. Users are auto-provisioned on first login with a configurable default role.
  • Phase 3 — SAML: Enterprise SAML 2.0 support via ITfoxtec.Identity.Saml2 or equivalent. SP metadata and assertion consumer endpoints.

SSO users have is_sso_user = true, sso_provider_id, and sso_subject_id set. They may have no password_hash (SSO-only) or may also have a local password (dual auth).

12.2 Authorization — Role-Based Access Control (RBAC)

Courier uses a simple three-role model. For local users, roles are assigned by administrators through the user management UI (/settings/users). For SSO users (Phase 2+), roles are either mapped from the identity provider's claims or assigned a configurable default role on auto-provisioning.

Roles:

RoleDescription
AdminFull access to all resources and settings. Can manage connections, keys, system settings, and view audit logs. Intended for platform administrators.
OperatorCan create/edit/execute jobs, chains, monitors. Can view connections and keys but cannot create or modify them. Cannot change system settings.
ViewerRead-only access to all resources. Can view job executions, audit logs, dashboards. Cannot create, modify, delete, or execute anything.

Permission matrix:

ResourceActionAdminOperatorViewer
JobsView
JobsCreate / Edit / Delete
JobsExecute / Cancel / Pause / Resume
Job ChainsView
Job ChainsCreate / Edit / Delete
Job ChainsExecute
ConnectionsView
ConnectionsCreate / Edit / Delete
ConnectionsTest
PGP KeysView metadata
PGP KeysGenerate / Import / Delete
PGP KeysExport public
PGP KeysExport private
PGP KeysCreate / Revoke share link
PGP KeysRetire / Revoke / Activate
SSH KeysView metadata
SSH KeysGenerate / Import / Delete
SSH KeysExport public
SSH KeysCreate / Revoke share link
File MonitorsView
File MonitorsCreate / Edit / Delete
File MonitorsActivate / Pause / Disable / Acknowledge
TagsView
TagsCreate / Edit / Delete / Assign
Audit LogView
UsersView / Create / Edit / Delete
UsersReset Password
Auth SettingsView / Update
System SettingsView
System SettingsUpdate
DashboardView

Enforcement:

Roles are enforced at the controller level via ASP.NET's [Authorize] attribute with role requirements. The role claim is embedded in the JWT access token issued by the Courier auth service:

// Admin-only endpoints
[Authorize(Roles = "admin")]
[HttpPost("connections")]
public async Task<ApiResponse<ConnectionDto>> CreateConnection(CreateConnectionRequest request)

// All authenticated users (any role)
[Authorize]
[HttpGet("jobs")]
public async Task<PagedApiResponse<JobDto>> ListJobs([FromQuery] JobFilter filter)

User management endpoints (/api/v1/users) are restricted to the admin role. Settings endpoints (/api/v1/settings/auth) are also admin-only. Auth and setup endpoints use [AllowAnonymous].

Guards:

  • CannotDeleteSelf — admins cannot delete their own account
  • CannotDemoteLastAdmin — the last remaining admin cannot have their role changed
  • DuplicateUsername — usernames must be unique

12.3 Data Protection at Rest

12.3.1 Envelope Encryption (PGP & SSH Key Material)

All cryptographic key material (PGP private keys, SSH private keys) and connection credentials (passwords, passphrases) are encrypted at rest using AES-256-GCM envelope encryption. This is described in detail in Section 7.5 (Cryptography) and Section 6.8 (SSH Key Store).

Architecture:

┌─────────────────┐
│  Azure Key Vault │
│                  │
│  Master Key      │◄──── RSA 2048-bit, hardware-backed, never exported
│  (KEK)           │      Used only to wrap/unwrap DEKs via API calls
└────────┬─────────┘
         │ wrapKey / unwrapKey (RSA-OAEP-256)
    ┌────▼────────────┐
    │  Data Encryption │
    │  Key (DEK)       │◄──── Random AES-256 key, unique per entity
    │                  │      Stored wrapped (opaque blob) alongside ciphertext
    └────────┬────────┘
             │ AES-256-GCM encrypt/decrypt
    ┌────────▼────────────┐
    │  Encrypted Data      │
    │  (BYTEA in Postgres) │
    │  [kek_ver | wrapped_dek | iv | tag | ciphertext | algo]
    └──────────────────────┘
  • Master key (KEK): An RSA 2048-bit key object in Azure Key Vault (not a secret). Hardware-backed where available. Never exported — the application only calls WrapKey and UnwrapKey via the Key Vault REST API's CryptographyClient. The plaintext KEK never exists in application memory, at startup or any other time. The EnvelopeEncryptionService (Section 7.3.7) holds a CryptographyClient reference (a remote API client), not key material.
  • Data Encryption Keys (DEKs): Random AES-256 keys generated per entity via RandomNumberGenerator. After encrypting the data, the DEK is wrapped by Key Vault and stored alongside the ciphertext. At decryption time, the wrapped DEK is sent to Key Vault for unwrapping, used locally for one operation, then zeroed from memory via CryptographicOperations.ZeroMemory. DEKs are never cached, pooled, or stored in any field — they exist in memory only for the duration of a single encrypt/decrypt call.
  • Algorithm: AES-256-GCM with a unique 96-bit IV per encryption operation. The IV and authentication tag are stored with the ciphertext.
  • KEK version: Each encrypted blob records which KEK version was used to wrap its DEK. This enables seamless rotation — Key Vault can unwrap using any previous version.
  • No key material in process memory: The EnvelopeEncryptionService is a singleton that holds no mutable state — no keys, no caches, no session data. See Section 7.3.7 for the full implementation and the explicit anti-patterns it avoids.

Startup requirement: Courier refuses to start if Azure Key Vault is unreachable. The startup health check calls KeyClient.GetKeyAsync to verify connectivity and KEK existence — this retrieves key metadata (name, version, algorithm), not the private key material. If this check fails, the application throws and enters a crash loop. This is a reachability check, not a key download.

12.3.2 Database-Level Encryption

PostgreSQL is configured with Transparent Data Encryption (TDE) at the storage level via Azure Database for PostgreSQL Flexible Server's encryption-at-rest feature. This provides defense-in-depth: even if the envelope encryption layer has a bug, the raw database files on disk are encrypted.

12.3.3 What Is Encrypted

DataEncryption MethodLocation
PGP private key materialAES-256-GCM envelope encryptionpgp_keys.private_key_data
PGP key passphrasesAES-256-GCM envelope encryptionpgp_keys.passphrase_hash
SSH private key materialAES-256-GCM envelope encryptionssh_keys.private_key_data
SSH key passphrasesAES-256-GCM envelope encryptionssh_keys.passphrase_hash
Connection passwordsAES-256-GCM envelope encryptionconnections.password_encrypted
Database files on diskAzure TDEStorage layer
Azure Key Vault keysHSM-backedKey Vault

12.3.4 What Is NOT Encrypted (and Why)

DataReason
PGP public keysPublic by design — shared with partners
SSH public keysPublic by design — placed in authorized_keys
Connection hostnames/portsNon-sensitive configuration data
Job configurationsMay reference entity IDs but contain no secrets directly
Audit log entriesHistorical records — no secret material is logged

12.4 Data Protection in Transit

12.4.1 API Layer (Client ↔ Courier)

All API traffic is served over HTTPS (TLS 1.2+). HTTP connections are rejected — not redirected — in production. The TLS certificate is managed by the deployment infrastructure (Azure App Service, Kubernetes Ingress, or reverse proxy).

HSTS: The Strict-Transport-Security header is set with a 1-year max-age and includeSubDomains:

app.UseHsts();  // Strict-Transport-Security: max-age=31536000; includeSubDomains

12.4.2 File Transfer Layer (Courier ↔ Remote Servers)

  • SFTP: All traffic encrypted via SSH. Algorithm preferences are configurable per connection (Section 6.7).
  • FTPS Explicit: AUTH TLS upgrade before credentials are sent. Minimum TLS version configurable (default: TLS 1.2).
  • FTPS Implicit: TLS from the first byte. Same TLS version floor.
  • Plain FTP: Supported for legacy compatibility only. The UI displays a prominent warning when creating a plain FTP connection. Plain FTP transmits credentials and data in cleartext.

12.4.3 Database Connections

PostgreSQL connections use SSL/TLS with certificate validation. The connection string includes SSL Mode=Require (staging/production) or SSL Mode=Prefer (development).

12.4.4 Azure Key Vault

All Key Vault operations use HTTPS. Authentication is via Managed Identity (production) or Azure CLI credentials (development).

12.5 Secrets Management

Courier manages two categories of secrets: application secrets (infrastructure credentials) and user secrets (connection passwords, key passphrases). They are handled differently.

12.5.1 Application Secrets

SecretProductionDevelopment
Database connection stringAzure Key VaultLocal secrets file (secrets.json)
JWT signing secretAzure Key Vaultappsettings.Development.json
Encryption KEKAzure Key Vaultappsettings.Development.json (base64)
Key Vault URIEnvironment variableEnvironment variable
Managed Identity client IDEnvironment variableN/A (uses Azure CLI)

Production: Application secrets are stored in Azure Key Vault as Secret objects and loaded at startup via the AzureKeyVaultConfigurationProvider:

builder.Configuration.AddAzureKeyVault(
    new Uri(builder.Configuration["KeyVault:Uri"]!),
    new DefaultAzureCredential());

This loads configuration values (strings like the database connection string) into the .NET IConfiguration system. This is completely separate from the envelope encryption KEK, which is a Key Vault Key object accessed only via CryptographyClient.WrapKeyAsync / UnwrapKeyAsync at operation time, never loaded into configuration or process memory (see Section 7.3.7).

DefaultAzureCredential resolves to Managed Identity in production (no secrets on disk) and Azure CLI or Visual Studio credentials in development.

Development: Secrets that are not in Key Vault are stored in the .NET User Secrets store (secrets.json), which is outside the project directory and never committed to source control:

dotnet user-secrets set "ConnectionStrings:CourierDb" "Host=localhost;..."
dotnet user-secrets set "AzureAd:ClientSecret" "dev-client-secret"

12.5.2 User Secrets (Connection Credentials, Key Passphrases)

These are managed by the application itself and stored encrypted in PostgreSQL via the envelope encryption scheme described in Section 12.3.1. They are never written to configuration files, environment variables, or logs.

API surface rules:

  • Passwords and passphrases are accepted in POST/PUT request bodies (over HTTPS)
  • They are never returned in GET responses — only boolean indicators (e.g., hasPassword: true)
  • Private key export (PGP only) is an explicit action endpoint with audit logging
  • SSH private keys are never exportable via the API

12.6 API Security Hardening

12.6.1 CORS

Courier's API serves a single frontend origin. CORS is configured to allow only that origin:

builder.Services.AddCors(options =>
{
    options.AddPolicy("CourierFrontend", policy =>
    {
        policy.WithOrigins(builder.Configuration["Frontend:Origin"]!)
              .AllowAnyHeader()
              .AllowAnyMethod()
              .AllowCredentials();
    });
});

In development, the frontend origin is http://localhost:3000. In production, it is the deployed Courier frontend URL. No wildcard origins are permitted.

12.6.2 Security Headers

Applied via middleware on all responses:

app.Use(async (context, next) =>
{
    context.Response.Headers["X-Content-Type-Options"] = "nosniff";
    context.Response.Headers["X-Frame-Options"] = "DENY";
    context.Response.Headers["X-XSS-Protection"] = "0";  // Disabled per OWASP; CSP preferred
    context.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
    context.Response.Headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()";
    context.Response.Headers["Content-Security-Policy"] =
        "default-src 'self'; frame-ancestors 'none'; form-action 'self'";
    await next();
});

12.6.3 Request Size Limits

Maximum request body size is 50 MB, sufficient for PGP/SSH key imports and large job configurations, while preventing abuse:

builder.WebHost.ConfigureKestrel(options =>
{
    options.Limits.MaxRequestBodySize = 50 * 1024 * 1024;  // 50 MB
});

The key import endpoint (POST /api/v1/pgp-keys/import) accepts multipart/form-data with a single file. The file size is validated server-side before processing.

12.6.4 Input Validation & Injection Prevention

  • SQL injection: Eliminated by EF Core's parameterized queries. No raw SQL is executed from user input.
  • JSONB injection: Step configurations and watch targets are serialized/deserialized through strongly-typed C# objects, not raw string concatenation.
  • Path traversal: File paths in job step configurations are validated against an allowlist of base directories. Paths containing .., absolute paths outside the configured data directory, and symbolic links are rejected.
  • Command injection: The 7z CLI wrapper (Section 8.1.2) uses ProcessStartInfo.ArgumentList (individual arguments, no shell interpretation), an absolute binary path (/usr/bin/7z), and UseShellExecute = false. Input filenames are validated against a safe character set and rejected if they contain shell metacharacters, path traversal sequences, or control characters. All operations are sandboxed within the job's temp directory, and extracted file paths are verified post-extraction against Zip Slip attacks. See Section 8.1.2 for the full hardening specification.

12.6.5 Anti-Forgery

Since the API is purely JSON over bearer token authentication (no cookies), CSRF attacks are not applicable. The Authorization: Bearer header cannot be automatically attached by a browser in a cross-origin request without CORS cooperation, which is locked to the single frontend origin.

Token storage: Access tokens are held in memory only (JavaScript variable). Refresh tokens are stored in localStorage. While localStorage is accessible to XSS, the refresh token alone cannot access resources — it can only be exchanged for a new access token via POST /api/v1/auth/refresh, and token rotation ensures each refresh token is single-use.

12.7 Audit & Accountability

All security-relevant operations are recorded in the unified audit log (Section 13.3.5). The audit log is append-only — entries cannot be modified or deleted via any API endpoint.

Security-critical audit events:

OperationLogged Details
LoginUser ID, IP address
PasswordChangedUser ID
UserCreatedUser ID, username, role
UserUpdatedUser ID, changed fields
UserDeletedUser ID, username
UserPasswordResetAdmin user ID, target user ID
SetupInitializedAdmin user ID
PermissionDeniedUser ID, attempted action, required role, endpoint
PrivateKeyExportedUser ID, key fingerprint, export format
KeyGeneratedUser ID, algorithm, key fingerprint
KeyRevokedUser ID, key fingerprint, reason
ConnectionCreatedUser ID, connection name, host, protocol
ConnectionCredentialsUpdatedUser ID, connection ID (never the credential itself)
HostKeyApprovedUser ID, connection ID, fingerprint, policy
HostKeyRejectedUser ID, connection ID, presented fingerprint, expected fingerprint
SystemSettingChangedUser ID, setting key, old value, new value
FipsOverrideEnabledUser ID, connection ID, connection host
FipsOverrideUsedConnection ID, negotiated algorithms, protocol
PublicKeyShareLinkCreatedUser ID, key type, key ID, expiration
PublicKeyShareLinkUsedKey type, key ID, requesting IP address
PublicKeyShareLinkRevokedUser ID, key type, key ID
InsecureHostKeyPolicyUsedConnection ID, remote host, accepted fingerprint
InsecureTlsPolicyUsedConnection ID, remote host, cert subject, policy errors
WatcherAutoDisabledMonitor ID, overflow count, file count, reason

Audit log protection: The audit_log_entries table has no UPDATE or DELETE exposed via the application. The EF Core DbContext does not include a Remove or Update method for AuditLogEntry. Database-level protection can be added via a PostgreSQL trigger that rejects UPDATE and DELETE on the audit table.

12.8 Sensitive Data Handling Rules

Logging: Sensitive data is never written to application logs. Structured logging is configured with a deny-list of property names that are automatically redacted:

Log.Logger = new LoggerConfiguration()
    .Destructure.ByTransforming<CreateConnectionRequest>(r =>
        new { r.Name, r.Host, r.Port, Password = "[REDACTED]" })
    .CreateLogger();

Specific rules:

  • Connection passwords: Never logged, even at Debug level
  • Key passphrases: Never logged
  • Private key material: Never logged
  • Bearer tokens: Never logged in full — only the last 8 characters for correlation
  • Request/response bodies: Logged at Debug level for non-sensitive endpoints only. Key and connection endpoints exclude sensitive fields.

Error messages: Internal error details (stack traces, database errors) are never returned to the client. The API returns a generic message with a correlation reference (e.g., err_a1b2c3d4) that maps to the detailed error in server logs.

Temp files: Job execution temp directories (/data/courier/temp/\{executionId\}/) may contain decrypted or decompressed files. These directories are cleaned up immediately on job completion or failure (Section 5.8). Paused jobs retain their temp directory but it is only accessible to the Courier application process.

12.9 Network Security

Production deployment assumes the following network topology:

  • Courier API and background services run in a private subnet (Azure VNet or Kubernetes cluster)
  • The frontend is served from a CDN or static hosting with API calls routed through a reverse proxy / API gateway
  • PostgreSQL is accessible only from the private subnet (no public endpoint)
  • Azure Key Vault is accessed via private endpoint or service endpoint within the VNet
  • Outbound SFTP/FTP connections to partner servers are allowed through network security groups with destination allowlists

No inbound connections from the internet directly to the Courier API. All external traffic flows through the reverse proxy / load balancer which terminates TLS and forwards to the backend.

12.10 FIPS 140-2 / 140-3 Compliance

When security.fips_mode_enabled = true, Courier restricts all internal cryptographic operations to FIPS-approved algorithms and attempts to run on FIPS 140-2/140-3 validated cryptographic modules where the platform provides them. This section documents what that means, what is guaranteed, and where gaps remain.

12.10.1 What "FIPS Mode" Means for Courier

FIPS 140-2 and 140-3 compliance is a property of cryptographic modules, not applications. An application achieves compliance by using only FIPS-approved algorithms through FIPS-validated modules (listed on NIST's Cryptographic Module Validation Program, CMVP). Courier cannot itself be "FIPS-validated" — it relies on the validated status of the underlying platform modules.

What Courier guarantees in FIPS mode:

  • Only FIPS-approved algorithms are used for internal operations (AES, RSA 2048+, SHA-256+, ECDSA P-curves). Non-approved algorithms (SHA-1, MD5, DES, Blowfish, CAST5) are rejected at the application level.
  • Azure Key Vault wrap/unwrap operations use a FIPS 140-2 Level 2 (software keys) or Level 3 (HSM-backed keys) validated module for all KEK operations.
  • Algorithm restrictions are enforced programmatically regardless of whether the underlying OS module is running in FIPS mode.

What Courier does not guarantee:

  • That the .NET runtime's underlying cryptographic module (CNG on Windows, OpenSSL on Linux) is running in its FIPS-validated mode. This depends on OS/container configuration (see 12.10.2). Courier detects and logs the module state at startup, but correct FIPS module configuration is an operational responsibility, not an application-level guarantee.
  • That BouncyCastle (used for PGP format operations) is a FIPS-validated module. It is not, even when restricted to approved algorithms (see 12.10.2).
  • That outbound SFTP/FTPS connections use only FIPS-approved algorithms. Partner interoperability may require non-FIPS algorithms (see 12.10.5).

Compliance boundary:

┌──────────────────────────────────────────────────────────────────────┐
│                    COURIER FIPS BOUNDARY                              │
│                                                                      │
│  ┌─────────────────────────┐   Validated module?                     │
│  │  Azure Key Vault        │   YES — CMVP #4456 (or current)        │
│  │  (KEK wrap/unwrap)      │   Level 2 (software) / Level 3 (HSM)   │
│  └─────────────────────────┘                                         │
│                                                                      │
│  ┌─────────────────────────┐   Validated module?                     │
│  │  Windows CNG            │   YES — if OS FIPS policy enabled       │
│  │  (AES-GCM, RSA, SHA)   │   CMVP #4515 (or current per OS ver)   │
│  └─────────────────────────┘                                         │
│                                                                      │
│  ┌─────────────────────────┐   Validated module?                     │
│  │  OpenSSL 3.x FIPS       │   YES — if FIPS provider correctly     │
│  │  provider (Linux)       │   installed and activated               │
│  │  (AES-GCM, RSA, SHA)   │   CMVP #4282 (or current per version)  │
│  └─────────────────────────┘                                         │
│                                                                      │
│  ┌─────────────────────────┐   Validated module?                     │
│  │  BouncyCastle           │   NO — approved algorithms only,        │
│  │  (PGP format ops)       │   no CMVP certificate                   │
│  └─────────────────────────┘                                         │
│                                                                      │
│  ┌─────────────────────────┐   Validated module?                     │
│  │  SSH.NET / FluentFTP    │   NO — transport libraries, not         │
│  │  (outbound connections) │   cryptographic modules                  │
│  └─────────────────────────┘                                         │
│                                                                      │
└──────────────────────────────────────────────────────────────────────┘

12.10.2 Cryptographic Module Strategy

Azure Key Vault (KEK operations): FIPS 140-2 validated. Software-protected keys use Level 2; HSM-backed keys use Level 3. This is the strongest link in Courier's FIPS chain — the KEK never leaves a validated boundary.

Windows CNG (.NET on Windows): CNG is FIPS 140-2 validated (certificate varies by Windows version). To run .NET crypto through the validated module boundary, the Windows FIPS security policy must be enabled at the OS level:

# Group Policy: Computer Configuration → Windows Settings → Security Settings →
#   Local Policies → Security Options →
#   "System cryptography: Use FIPS compliant algorithms"

# Or registry:
HKLM\SYSTEM\CurrentControlSet\Control\Lsp\FipsAlgorithmPolicy\Enabled = 1

When this policy is enabled, .NET's System.Security.Cryptography types route through CNG's validated boundary. When disabled, they still use CNG but not necessarily in its validated mode.

OpenSSL 3.x FIPS provider (.NET on Linux / containers): OpenSSL 3.x includes a FIPS provider module that is separately validated (CMVP #4282, version-dependent). Activating it requires more than copying an openssl.cnf — the FIPS provider must be:

  1. Present in the container image: The base image must include a build of OpenSSL 3.x that ships the FIPS provider module (fips.so). Not all distro packages include it — some require installing openssl-fips-provider or building from source.
  2. Self-tested: The FIPS module runs a self-test on first activation, producing a checksum file. This self-test must pass for the module to operate in validated mode.
  3. Configured as the default provider: openssl.cnf must activate the fips provider and optionally the base provider (for non-crypto operations like encoding), and set fips as the default property query.
# /etc/ssl/openssl.cnf (simplified — actual config depends on distro)
[openssl_init]
providers = provider_sect
alg_section = algorithm_sect

[provider_sect]
fips = fips_sect
base = base_sect

[fips_sect]
activate = 1

[base_sect]
activate = 1

[algorithm_sect]
default_properties = fips=yes

Operational requirement: The Dockerfile must use a base image where the OpenSSL FIPS provider is properly installed and self-tested. This is validated at image build time (see 14.2) and at application startup (see startup checks below). If the FIPS provider is not active, Courier logs a warning but does not refuse to start — the application-level algorithm restrictions still apply, but operations are not running through a validated module boundary.

Startup detection:

// Application startup — detect and log FIPS module state
// Note: .NET does not expose a simple "is FIPS module active" API.
// We probe by checking platform indicators.

if (OperatingSystem.IsWindows())
{
    // Windows: check registry for FIPS policy
    using var key = Registry.LocalMachine.OpenSubKey(
        @"SYSTEM\CurrentControlSet\Control\Lsp\FipsAlgorithmPolicy");
    var enabled = key?.GetValue("Enabled") as int? == 1;
    logger.LogInformation("Windows FIPS policy: {State}",
        enabled ? "Enabled (CNG validated mode)" : "Disabled");
    if (!enabled)
        logger.LogWarning("Windows FIPS policy is not enabled. " +
            "Crypto operations use CNG but not in validated mode. " +
            "Courier still restricts to approved algorithms.");
}
else if (OperatingSystem.IsLinux())
{
    // Linux: probe whether OpenSSL FIPS provider is active
    try
    {
        // Attempt an operation that requires FIPS — if the provider
        // is active with default_properties=fips=yes, this succeeds.
        // A more robust check: shell out to `openssl list -providers`
        // and verify "fips" appears with status "active".
        var result = Process.Start(new ProcessStartInfo
        {
            FileName = "openssl",
            Arguments = "list -providers",
            RedirectStandardOutput = true,
        })!;
        var output = await result.StandardOutput.ReadToEndAsync();
        var fipsActive = output.Contains("name: OpenSSL FIPS Provider")
                      && output.Contains("status: active");
        logger.LogInformation("OpenSSL FIPS provider: {State}",
            fipsActive ? "Active (validated mode)" : "Not active");
        if (!fipsActive)
            logger.LogWarning("OpenSSL FIPS provider is not active. " +
                "Crypto operations use OpenSSL but not in validated mode. " +
                "Courier still restricts to approved algorithms. " +
                "See Section 12.10.2 for container configuration requirements.");
    }
    catch (Exception ex)
    {
        logger.LogWarning(ex, "Could not detect OpenSSL FIPS provider status.");
    }
}

Envelope encryption specifics:

OperationModuleValidated?
Key wrapping (KEK)Azure Key Vault (RSA-OAEP-256)Yes — FIPS 140-2 Level 2/3
Data encryption (DEK)AesGcm → CNG (Windows) or OpenSSL (Linux)Yes — if OS/container FIPS mode enabled
DEK generationRandomNumberGenerator → CNG/OpenSSL CSPRNGYes — if OS/container FIPS mode enabled
IV generationRandomNumberGenerator → CNG/OpenSSL CSPRNGYes — if OS/container FIPS mode enabled

PGP operations — BouncyCastle:

The standard BouncyCastle NuGet package (Portable.BouncyCastle / BouncyCastle.Cryptography) is not FIPS-validated. The Legion of the Bouncy Castle does publish a FIPS-certified line (bc-fips for core crypto, bcpg-fips for OpenPGP) that carries CMVP validation. However:

  • bc-fips and bcpg-fips are Java libraries. The .NET equivalents (bc-fips-csharp) exist but with more limited availability and documentation.
  • The FIPS-certified BouncyCastle line has a different API surface and licensing model (requires a support agreement).
  • Migration from standard BouncyCastle to bc-fips-csharp + bcpg-fips-csharp is a material effort that should be evaluated based on the organization's compliance requirements.

Courier's V1 approach: Use standard BouncyCastle configured to use only FIPS-approved algorithms (AES-256, RSA 2048+, SHA-256+). This provides algorithm compliance but not module validation for PGP operations. The ICryptoProvider abstraction (Section 7) isolates BouncyCastle usage, enabling a future migration to bcpg-fips-csharp if the organization requires validated-module PGP operations.

PGP OperationAlgorithm RestrictionBouncyCastle Config
Symmetric encryptionAES-128, AES-192, AES-256 onlySymmetricKeyAlgorithmTag.Aes256 — explicitly set, reject others
Asymmetric encryptionRSA 2048+ onlyPublicKeyAlgorithmTag.RsaGeneral — reject ElGamal, DSA
HashingSHA-256, SHA-384, SHA-512 onlyHashAlgorithmTag.Sha256 minimum — reject SHA-1, MD5
SigningRSA 2048+ with SHA-256+ onlyReject SHA-1 signatures

Documented limitation: PGP operations use only FIPS-approved algorithms but do not run through a FIPS-validated cryptographic module in V1. This is an accepted risk. If validated PGP operations are required, the migration path is to bcpg-fips-csharp behind the existing ICryptoProvider abstraction.

12.10.3 FIPS-Approved Algorithms

Allowed in FIPS mode (internal operations):

CategoryApproved Algorithms
Symmetric encryptionAES-128, AES-192, AES-256 (GCM or CBC mode)
Asymmetric encryptionRSA 2048, 3072, 4096
Key exchangeECDH P-256, P-384, P-521
Digital signaturesRSA 2048+ with SHA-256+, ECDSA P-256/P-384/P-521
HashingSHA-256, SHA-384, SHA-512, SHA-3
Key wrappingRSA-OAEP-256 (via Azure Key Vault)
Random generationCNG/OpenSSL CSPRNG (DRBG)

Explicitly prohibited in FIPS mode (internal operations):

AlgorithmReason
SHA-1Deprecated — collision attacks demonstrated
MD5Broken — not approved since FIPS 180-4
DES / 3DESDeprecated — insufficient key length
RSA < 2048Insufficient key length per NIST SP 800-131A
Ed25519 / Curve25519Not yet in most FIPS-validated modules (see 12.10.4)
Blowfish / CAST5 / IDEANot NIST-approved
ElGamalNot NIST-approved

12.10.4 Ed25519 / Curve25519 Handling

Ed25519 and Curve25519 are modern, high-performance algorithms approved in FIPS 186-5 but not yet included in most FIPS-validated cryptographic modules (CNG, OpenSSL FIPS provider).

Courier's approach:

  • The algorithm enums (SshKeyType, PgpAlgorithm) retain Ed25519 and Curve25519 values
  • A runtime FIPS mode flag (system_settings.fips_mode_enabled, default: true) gates their availability
  • When FIPS mode is enabled: Ed25519/Curve25519 key generation is rejected with error code 4011: Algorithm not available in FIPS mode. Import of existing Ed25519 keys is allowed (they may be needed for partner connections) but flagged with a warning in the UI and audit log
  • When FIPS mode is disabled: All algorithms are available without restriction
  • Per-connection FIPS override: Individual connections can set fips_override = true to allow non-FIPS algorithms for that connection only, regardless of the global FIPS mode. This is logged as a security event

Future: When FIPS-validated modules include Ed25519, the restriction can be lifted by updating the FIPS algorithm allowlist without code changes.

12.10.5 Connection-Level FIPS Configuration

Outbound connections (SFTP, FTP, FTPS) may need to negotiate algorithms that are not FIPS-approved to interoperate with partner servers. This is handled via a per-connection fips_override flag.

Default behavior (FIPS mode enabled, no override):

  • SFTP: SSH.NET configured to prefer FIPS-approved key exchange (ECDH P-256/P-384, diffie-hellman-group14-sha256, diffie-hellman-group16-sha512), encryption (aes256-ctr, aes128-ctr, [email protected]), MAC (hmac-sha2-256, hmac-sha2-512), and host key (rsa-sha2-256, rsa-sha2-512, ecdsa-sha2-nistp256) algorithms
  • FTPS: TLS 1.2+ with FIPS-approved cipher suites only (AES-based, no RC4, no 3DES)

With FIPS override (fips_override = true on connection):

  • All algorithms supported by SSH.NET / FluentFTP are available, including chacha20-poly1305, curve25519-sha256, Ed25519 host keys
  • The connection is flagged in the UI with a "Non-FIPS" badge
  • Every connection using the override generates an audit event: FipsOverrideUsed with connection ID, negotiated algorithms, and user who configured the override

Connection entity change: Add fips_override to the connection schema:

ALTER TABLE connections ADD COLUMN fips_override BOOLEAN NOT NULL DEFAULT FALSE;

12.10.6 FIPS Enforcement Architecture

┌───────────────────────────────────────────────────────────────────┐
│                      FIPS ENFORCEMENT                             │
│                                                                   │
│  Global: system_settings.fips_mode_enabled = true (default)       │
│                                                                   │
│  ┌─────────────────────────────────────────────────────────────┐ │
│  │  INTERNAL OPERATIONS (approved algorithms; validated module  │ │
│  │  depends on OS/container FIPS configuration)                │ │
│  │                                                             │ │
│  │  Encryption at rest ──► .NET AesGcm → CNG/OpenSSL          │ │
│  │  DEK generation ──────► .NET RandomNumberGenerator → ditto  │ │
│  │  Key wrapping ────────► Azure Key Vault RSA-OAEP-256 ✓     │ │
│  │  Key generation ──────► .NET RSA/ECDsa → CNG/OpenSSL       │ │
│  │  Hashing ─────────────► .NET SHA256/384/512 → CNG/OpenSSL  │ │
│  │                                                             │ │
│  │  ✓ = always validated     (others) = validated if OS FIPS on│ │
│  └─────────────────────────────────────────────────────────────┘ │
│                                                                   │
│  ┌─────────────────────────────────────────────────────────────┐ │
│  │  PGP OPERATIONS (approved algorithms only; NOT a validated  │ │
│  │  module — accepted risk, path to bcpg-fips-csharp exists)   │ │
│  │                                                             │ │
│  │  BouncyCastle ──► AES-256 only, RSA 2048+, SHA-256+        │ │
│  │                   Reject: CAST5, IDEA, SHA-1, ElGamal      │ │
│  └─────────────────────────────────────────────────────────────┘ │
│                                                                   │
│  ┌─────────────────────────────────────────────────────────────┐ │
│  │  OUTBOUND CONNECTIONS (FIPS default, per-connection override)│ │
│  │                                                             │ │
│  │  fips_override = false ──► FIPS cipher suites only          │ │
│  │  fips_override = true  ──► All algorithms + audit event     │ │
│  └─────────────────────────────────────────────────────────────┘ │
│                                                                   │
└───────────────────────────────────────────────────────────────────┘

12.10.7 Validation & Compliance Evidence

To support FIPS compliance assessments:

  • Algorithm inventory: The /api/v1/settings endpoint exposes fips_mode_enabled status. A future admin endpoint can return the complete list of algorithms in use across all connections and keys.
  • Module state logging: Application startup logs record whether the underlying cryptographic module (CNG or OpenSSL FIPS provider) is running in validated mode (see 12.10.2). These logs provide auditable evidence of module state per deployment.
  • Audit trail: All cryptographic operations are logged with the algorithm used (algorithm field in audit details). Auditors can query the audit log to verify no prohibited algorithms were used for internal operations.
  • FIPS override tracking: All connections with fips_override = true are queryable. Audit events record every use of the override with the negotiated algorithm suite.
  • Module documentation: This section records which cryptographic modules are used, their CMVP validation status, accepted risks (BouncyCastle standard edition), and the migration path to validated PGP operations (bcpg-fips-csharp).
  • Container configuration: Dockerfiles must use a base image with the OpenSSL FIPS provider correctly installed. Validation of provider status is automated at startup (see 12.10.2), not assumed from Dockerfile alone.

12.10.8 System Settings Addition

INSERT INTO system_settings (key, value, description, updated_by) VALUES
    ('security.fips_mode_enabled', 'true', 'Enforce FIPS 140-2 approved algorithms for internal operations', 'system'),
    ('security.fips_override_require_admin', 'true', 'Require Admin role to set fips_override on connections', 'system'),
    ('security.public_key_share_links_enabled', 'false', 'Allow Admins to generate unauthenticated share links for public keys', 'system'),
    ('security.max_share_link_days', '30', 'Maximum expiration period in days for public key share links', 'system'),
    ('security.insecure_trust_require_admin', 'true', 'Require Admin role to set AlwaysTrust (SSH) or Insecure (TLS) on connections', 'system'),
    ('security.insecure_trust_allow_production', 'false', 'Allow insecure trust policies in production environments', 'system');

When fips_mode_enabled is true:

  • Key generation restricts to FIPS-approved algorithms
  • BouncyCastle PGP operations are restricted to approved algorithms
  • Connections without fips_override negotiate FIPS-only cipher suites
  • Algorithm enums filter UI dropdowns to show only approved options

When fips_override_require_admin is true (default), only Admin role users can toggle fips_override on a connection. This prevents Operators from accidentally weakening security posture.

12.11 Security Summary

┌──────────────────────────────────────────────────────────────────┐
│                    SECURITY LAYERS                               │
│                                                                  │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │  FIPS: Approved algorithms enforced; validated modules where    ││
│  │  platform provides them; per-connection override for partners  ││
│  └─────────────────────────────────────────────────────────────┘│
│  ┌─────────────────────────────────────────────────────────────┐│
│  │  Network: Private subnet, NSG allowlists, no public API    ││
│  └─────────────────────────────────────────────────────────────┘│
│  ┌─────────────────────────────────────────────────────────────┐│
│  │  Transport: TLS 1.2+ everywhere (API, DB, Key Vault, FTPS) ││
│  └─────────────────────────────────────────────────────────────┘│
│  ┌─────────────────────────────────────────────────────────────┐│
│  │  Authentication: Entra ID JWT validation (single tenant)    ││
│  └─────────────────────────────────────────────────────────────┘│
│  ┌─────────────────────────────────────────────────────────────┐│
│  │  Authorization: 3-role RBAC (Admin, Operator, Viewer)       ││
│  └─────────────────────────────────────────────────────────────┘│
│  ┌─────────────────────────────────────────────────────────────┐│
│  │  API Hardening: CORS, security headers, input validation    ││
│  └─────────────────────────────────────────────────────────────┘│
│  ┌─────────────────────────────────────────────────────────────┐│
│  │  Data at Rest: AES-256-GCM envelope encryption + Azure TDE  ││
│  └─────────────────────────────────────────────────────────────┘│
│  ┌─────────────────────────────────────────────────────────────┐│
│  │  Secrets: Key Vault (prod), User Secrets (dev), no env vars ││
│  └─────────────────────────────────────────────────────────────┘│
│  ┌─────────────────────────────────────────────────────────────┐│
│  │  Audit: Append-only log, all security events, no redaction  ││
│  └─────────────────────────────────────────────────────────────┘│
│                                                                  │
└──────────────────────────────────────────────────────────────────┘

12.12 Threat Model & Trust Boundaries

This section identifies the critical assets, likely attackers, plausible attack paths, and the mitigations Courier applies. It is not a formal STRIDE analysis but provides the "what keeps us up at night" foundation for security review.

12.12.1 Trust Boundaries

┌───────────────────────────────────────────────────────────────────┐
│  TRUST BOUNDARY 1: Organization Perimeter                         │
│                                                                   │
│  ┌─────────────────────────────────────────────────────────────┐  │
│  │  TRUST BOUNDARY 2: Courier Internal                         │  │
│  │                                                             │  │
│  │  ┌──────────┐   ┌──────────┐   ┌──────────────────────┐    │  │
│  │  │ API Host │   │ Worker   │   │ PostgreSQL           │    │  │
│  │  │          │   │          │   │ (encrypted at rest,  │    │  │
│  │  │          │   │          │   │  private subnet)     │    │  │
│  │  └────┬─────┘   └────┬─────┘   └──────────────────────┘    │  │
│  │       │              │                                      │  │
│  └───────┼──────────────┼──────────────────────────────────────┘  │
│          │              │                                         │
│  ┌───────▼──────────────▼──────────────────────────────────────┐  │
│  │  TRUST BOUNDARY 3: Azure Managed Services                   │  │
│  │  Azure Key Vault (FIPS validated) │ Azure Blob (archive)    │  │
│  └─────────────────────────────────────────────────────────────┘  │
│                                                                   │
└───────────────────────┬───────────────────────────────────────────┘
                        │
    ════════════════════╪═══════════════════════════════  (Internet)
                        │
┌───────────────────────▼───────────────────────────────────────────┐
│  UNTRUSTED: Partner SFTP/FTP Servers                              │
│  (any host key, any certificate, any file content)                │
└───────────────────────────────────────────────────────────────────┘

Data crossing each boundary is subject to specific controls: authentication at boundary 1 (Entra ID), encryption at boundary 2 (AES-256-GCM envelope encryption, TLS for all connections), managed-service IAM at boundary 3, and protocol-level verification at the internet boundary (host key verification, TLS cert validation).

12.12.2 Asset Inventory

AssetSensitivityLocationPrimary Protection
Connection credentials (passwords)CriticalPostgreSQL, encrypted with AES-256-GCM + Key Vault KEKEnvelope encryption (Section 12.3), KEK rotation, Admin-only access
SSH private keysCriticalPostgreSQL, encrypted same as credentialsEnvelope encryption, private key export denied by default (Section 6.8)
PGP private keys (passphrases)CriticalPostgreSQL, passphrase encrypted same as credentialsEnvelope encryption, private key export denied by default (Section 7.3)
Azure Key Vault KEKCriticalAzure Key Vault (HSM-backed in production)Azure RBAC, private endpoint, FIPS 140-2 Level 2/3
Files in transitHighJob temp directory, partner serversTLS/SSH encryption in transit, temp dir cleanup, per-execution sandbox
Files at rest (temp)High/data/courier/temp/\{executionId\}/Per-execution isolation, 7-day orphan cleanup, container-local storage
Audit logHigh (integrity)PostgreSQL partitioned tablesAppend-only (no update/delete API), tamper detection via monthly checksums
User identity tokensHighIn-memory (API), never persistedShort-lived (Entra ID default), validated on every request
Database connection stringHighKey Vault (prod), User Secrets (dev)Never in env vars, never logged
Partner server host keysMediumknown_hosts tableTOFU or Manual verification, mismatch detection
Job definitionsMediumPostgreSQLRBAC (Operator+ to create/edit), versioned, audit logged
Public keys (PGP/SSH)LowPostgreSQL, exportableAuthenticated export by default, optional share links (Section 7.3.5)

12.12.3 Threat Scenarios

#AttackerTarget AssetAttack PathImpactMitigationSection
T1External: Network MITMFiles in transit, credentialsIntercept SFTP/FTPS connection to partner server via DNS spoofing or ARP poisoningCredential theft, file exfiltration, file injectionSSH host key verification (TOFU/Manual), TLS cert validation (SystemTrust/PinnedThumbprint). AlwaysTrust/Insecure policies are Admin-only, FIPS-blocked, production-blocked, and audited per use.6.7, 6.3.2
T2External: Malicious archiveWorker filesystem, downstream systemsUpload a crafted archive (via partner SFTP or manual upload) containing Zip Slip paths, decompression bombs, or symlink exploitsDisk exhaustion, arbitrary file overwrite, sandbox escapePath traversal validation, symlink rejection, decompression limits (20 GB / 10K files / 200:1 ratio), no recursive extraction, post-extraction sweep.8.1.8
T3Insider: Compromised OperatorConnection credentials, private keysUse legitimate Operator role access to export credentials or reconfigure jobs to exfiltrate filesCredential exfiltration, unauthorized data accessOperators cannot export private keys (Admin-only). Operators cannot set AlwaysTrust or Insecure trust policies (Admin-only). All credential access is audit-logged. Connection passwords are never returned in API responses.7.3.5, 12.2
T4Insider: Compromised AdminAnythingAdmin has full system access. Could disable FIPS mode, enable AlwaysTrust everywhere, export keys, create insecure connections.Full system compromiseAdmin actions are the highest-audited events. FIPS override, insecure trust policies, key exports, and share link creation all generate audit events. Audit log is append-only with no delete API. Admin cannot suppress audit entries. External SIEM integration (V2) provides tamper-resistant audit storage.12.7
T5External: SQL injectionDatabase (all data)Inject SQL via API parametersFull database read/writeEF Core parameterized queries exclusively. No raw SQL from user input. JSONB serialized through typed C# objects.12.6.4
T6External: Command injection via 7zWorker filesystemCraft a filename containing shell metacharacters that gets passed to the 7z CLIArbitrary command executionNo shell invocation (UseShellExecute = false), ArgumentList (not string interpolation), filename sanitization, absolute binary path, sandbox isolation.8.1.2
T7External: Token theftUser session, API accessSteal Entra ID access token via XSS, token leak in logs, or intercepted API callImpersonation, unauthorized actions as victimCORS locked to single origin, security headers (CSP, X-Frame-Options), tokens never logged (sensitive data redaction), short-lived tokens (1hr), sessionStorage only (not localStorage), TLS required. See 11.2.2 for SPA-specific auth security.11.2.2, 12.6.1, 12.6.2, 12.8
T8Operational: FIPS policy bypassCompliance postureAdmin enables fips_override on connections or disables fips_mode_enabled globallyNon-compliant algorithm negotiation in audited environmentEvery FIPS override generates FipsOverrideUsed audit event with negotiated algorithms. Global FIPS toggle change is audit-logged. Non-FIPS connections display a badge in the UI. Compliance reporting query surfaces all overrides.12.10.5
T9Operational: Key exfiltrationPGP/SSH private keysAdmin creates share link for public key (low risk) or exports private key (high risk)Partner impersonation, decryption of intercepted filesPrivate key export denied by default. Share links are for public keys only, disabled by default, Admin-only, time-limited (max 30 days), revocable, and download-counted. All actions audit-logged.7.3.5
T10External: Denial of serviceAPI availabilityFlood the API with requests or queue excessive jobsAPI unresponsive, job queue backed upRate limiting (Section 12.6), concurrency semaphore (Section 5.8), queue depth monitoring, request size limits (10 MB body).12.6.3
T11External/Insider: Process memory dumpDEKs, plaintext credentials during useAttacker with container access dumps Worker process memory to extract in-flight secretsCredential and key material exposure for any operations active at dump timeKEK never in process memory (only in Key Vault HSM boundary). DEKs exist only for the duration of a single encrypt/decrypt call and are zeroed via CryptographicOperations.ZeroMemory. No DEK cache, no credential cache, no pre-decrypted store. Blast radius limited to operations in-flight at the exact moment of the dump. See Section 7.3.7 anti-patterns.7.3.7

12.12.4 Accepted Risks (V1)

These are known risks accepted in V1 with documented mitigations and V2 plans:

RiskSeverityJustificationV2 Mitigation
BouncyCastle is not a FIPS-validated moduleMediumAlgorithm compliance is enforced; only module validation is missing. Acceptable if the organization's compliance requirement is algorithm-level, not module-level.Migrate to bcpg-fips-csharp behind ICryptoProvider.
Single Worker instance is a single point of failureMediumCrash recovery is automatic (restart + re-pick queued jobs). Running jobs are lost and must be re-triggered. Acceptable for V1 internal workload.Quartz cluster mode + event-driven scheduling enables horizontal Worker scaling.
Audit log is stored in the same database as application dataMediumA compromised database admin could theoretically delete audit entries. Append-only API prevents application-level tampering, but not DBA-level.External SIEM integration (immutable log shipping).
Admin role has no separation of dutiesMediumA single Admin can both configure insecure settings and suppress the UI warnings (though not the audit entries). Acceptable for small teams.Introduce "Security Admin" role distinct from "System Admin" with approval workflows for security-sensitive changes.
No intrusion detection on partner file contentLowFiles received from partners are transferred as opaque blobs. Courier does not scan for malware or data loss prevention.Integration with Azure Defender or ClamAV for file content scanning.