Cryptography & Key Store
AES-256-GCM envelope encryption, PGP key management, and Azure Key Vault integration.
This section covers the PGP encryption/decryption engine used by the pgp.encrypt and pgp.decrypt job steps, the file signing and verification system, and the centralized Key Store that manages all cryptographic key material.
7.1 Unified Crypto Interface
All cryptographic operations go through a common provider interface, consistent with the plugin pattern used by the Job Step registry and Compression providers.
public interface ICryptoProvider
{
Task<CryptoResult> EncryptAsync(
EncryptRequest request,
IProgress<CryptoProgress> progress,
CancellationToken cancellationToken);
Task<CryptoResult> DecryptAsync(
DecryptRequest request,
IProgress<CryptoProgress> progress,
CancellationToken cancellationToken);
Task<CryptoResult> SignAsync(
SignRequest request,
IProgress<CryptoProgress> progress,
CancellationToken cancellationToken);
Task<VerifyResult> VerifyAsync(
VerifyRequest request,
IProgress<CryptoProgress> progress,
CancellationToken cancellationToken);
}
public record EncryptRequest(
string InputPath,
string OutputPath,
IReadOnlyList<Guid> RecipientKeyIds, // One or more public keys
Guid? SigningKeyId, // Optional: sign-then-encrypt
OutputFormat Format); // Armored (.asc) or Binary (.pgp)
public record DecryptRequest(
string InputPath,
string OutputPath,
Guid PrivateKeyId,
bool VerifySignature); // Also verify if signed
public record SignRequest(
string InputPath,
string OutputPath,
Guid SigningKeyId,
SignatureMode Mode); // Detached, Inline, or Clearsign
public record VerifyRequest(
string InputPath,
string? DetachedSignaturePath, // null = inline/clearsigned
Guid? ExpectedSignerKeyId); // null = accept any known key
public record VerifyResult(
bool IsValid,
VerifyStatus Status, // Valid, Invalid, UnknownSigner, ExpiredKey, RevokedKey
string? SignerFingerprint,
DateTime? SignatureTimestamp);
public enum VerifyStatus
{
Valid,
Invalid,
UnknownSigner,
ExpiredKey,
RevokedKey
}
The pgp.encrypt, pgp.decrypt, pgp.sign, and pgp.verify job steps delegate entirely to this interface. Step configuration references keys by their Key Store ID, which the engine resolves at runtime.
7.2 Library Selection
Courier uses BouncyCastle (Portable.BouncyCastle NuGet package) as its PGP engine. BouncyCastle is the de facto .NET library for OpenPGP operations — it is mature, actively maintained, and widely deployed in enterprise environments.
Key capabilities used:
- RSA key pair generation (2048, 3072, 4096 bit)
- OpenPGP encryption and decryption with support for multiple recipients
- Detached, inline, and clearsigned signatures
- ASCII-armored and binary format output
- Passphrase-protected private key handling
- Streaming API for processing large files without full memory load
BouncyCastle also supports ECC algorithms (Curve25519, NIST P-256/P-384), which positions Courier for the planned ECC key support without changing libraries.
7.3 Key Store
The Key Store is a centralized, encrypted vault for all PGP key material. It is a first-class entity in Courier with its own management API and UI surface.
7.3.1 Key Entity
Each key in the store has the following metadata:
| Field | Type | Description |
|---|---|---|
id | UUID | Internal identifier used in job step configuration |
name | TEXT | Human-readable label (e.g., "Partner X Production Key") |
fingerprint | TEXT | Full PGP fingerprint (40 hex chars) |
key_id | TEXT | Short key ID (16 hex chars) for display |
algorithm | ENUM | RSA_2048, RSA_3072, RSA_4096, ECC_CURVE25519, etc. |
key_type | ENUM | PublicOnly, KeyPair (public + private) |
purpose | TEXT | Free-text notes (e.g., partner name, use case) |
status | ENUM | Active, Expiring, Retired, Revoked, Deleted |
created_at | TIMESTAMP | When the key was generated or imported |
expires_at | TIMESTAMP | Key expiration date (nullable for non-expiring keys) |
public_key_data | TEXT | ASCII-armored public key |
private_key_data | BYTEA | AES-256 encrypted private key material (null for public-only) |
passphrase_hash | TEXT | Encrypted passphrase for passphrase-protected private keys |
created_by | TEXT | User who generated or imported the key |
The algorithm enum is designed with room for expansion. Adding ECC support in the future requires adding new enum values and implementing the BouncyCastle ECC key generation — no schema migration needed.
7.3.2 Key Status Lifecycle
Keys follow a defined lifecycle with five states:
┌────────┐
│ Active │
└──┬─┬─┬─┘
│ │ │
│ │ └──────────────┐ (manual revoke)
│ │ │
│ │ (approaching │
│ │ expiration) │
│ │ ┌────▼────┐
│ │ │ Revoked │ ── cannot be used by any job
│ │ └─────────┘
│ │
│ ┌────▼─────┐
│ │ Expiring │ ── still usable, emits warning events
│ └──┬──┬────┘
│ │ │
│ │ └── (expires) ──► Retired
│ │
│ └── (manual revoke) ──► Revoked
│
│ (manual retire)
┌────▼────┐
│ Retired │ ── cannot be used by new jobs, existing references warn
└─────────┘
│
│ (manual delete)
┌────▼────┐
│ Deleted │ ── soft delete, key material purged, metadata retained for audit
└─────────┘
Active: Key is available for use by any job. Default state after generation or import.
Expiring: Key's expiration date is within the configured warning window (default: 30 days). The key is still fully functional but domain events are emitted (KeyExpiringSoon) for V2 alerting. A background service checks expiration dates daily and transitions keys automatically.
Retired: Key has been manually retired or has passed its expiration date. Jobs cannot use a retired key for new operations. If an existing job references a retired key, the step fails with a descriptive error indicating the key must be updated. Decryption and signature verification with retired keys remain possible to handle legacy files.
Revoked: Key has been explicitly revoked, indicating it should no longer be trusted under any circumstances. All operations (including decryption and verification) are blocked. Verification against a revoked key returns VerifyStatus.RevokedKey. This is a terminal state — a revoked key cannot be re-activated.
Deleted: Soft delete. Private key material is securely purged (overwritten with zeros before removal). Public key data and metadata are retained for audit trail purposes. The key no longer appears in normal queries but is visible in audit history.
7.3.3 Key Generation
Courier can generate RSA key pairs internally:
- Supported sizes: 2048, 3072, 4096 bit (default: 4096)
- Key format: OpenPGP-compatible key pair with configurable user ID (name + email)
- Expiration: Optional expiration date set at generation time
- Passphrase: Optional passphrase protection on the private key
Generation uses BouncyCastle's RsaKeyPairGenerator with a secure random source. The key pair is immediately encrypted and stored in the Key Store. The public key is made available for export.
7.3.4 Key Import
Keys can be imported from external sources in the following formats:
| Format | Extension(s) | Import Behavior |
|---|---|---|
| ASCII Armored | .asc, .txt | Parsed directly by BouncyCastle |
| Binary | .pgp, .gpg | Parsed directly by BouncyCastle |
| Keyring | .kbx, .gpg | Extracted into individual keys on import |
On import, Courier:
- Parses the key material and extracts metadata (fingerprint, algorithm, expiration, user IDs)
- Validates key integrity (verifies self-signatures)
- Checks for fingerprint collisions with existing keys in the store
- If importing a private key, prompts for the passphrase and verifies it can unlock the key
- Encrypts private key material via envelope encryption (random DEK + Key Vault wrap) before storage
- Creates the Key Store record with status
Active
If the imported key is already expired, it is imported with status Retired and a warning is returned.
7.3.5 Key Export
Public key export (authenticated, all roles):
- Formats: ASCII-armored (.asc) or binary (.pgp)
- Access: Available via API (
GET /api/v1/pgp-keys/\{id\}/export/public?format=armored) and the frontend UI - Authentication required — all public key exports go through the standard Entra ID auth pipeline. Even public keys reveal identity, partner relationships, and operational metadata. All exports are logged as audit events.
Shareable public key links (optional, Admin-only):
For scenarios where a partner needs to download a public key without Entra ID credentials, Admins can generate a time-limited, single-purpose shareable link:
- Endpoint:
POST /api/v1/pgp-keys/\{id\}/share→ returns a URL with a cryptographic token - URL format:
/api/v1/pgp-keys/shared/\{token\}— no authentication required on this endpoint - Token: 256-bit random, stored hashed (SHA-256) in the database alongside key ID, expiration, and creator
- Default expiration: 72 hours (configurable per link, maximum 30 days, set via
security.max_share_link_dayssystem setting) - Single key, read-only: The token grants access to one specific public key export only, nothing else
- Audit: Link creation, every download via the link, and link expiration are all logged with the requesting IP address
- Revocation: Links can be revoked before expiration via
DELETE /api/v1/pgp-keys/\{id\}/share/\{token\} - Disabled by default: The
security.public_key_share_links_enabledsystem setting defaults tofalse. Admins must explicitly enable the feature before any shareable links can be generated
Private key export is a sensitive operation:
- Requires explicit user confirmation
- Exported private key is optionally re-encrypted with a user-provided passphrase
- The export event is prominently logged in the key audit trail
- A future enhancement could require multi-user approval for private key export
7.3.6 Private Key Encryption at Rest
V1 Implementation Note — Local KEK (No Azure Key Vault)
The design below describes the target architecture using Azure Key Vault for KEK management. In V1, we opted not to use Azure Key Vault. Instead, credential encryption uses a local AES-256-GCM envelope encryption scheme with a KEK sourced from an environment variable (
COURIER_ENCRYPTION_KEY, base64-encoded 256-bit key) orappsettings.jsonconfiguration (Encryption:KeyEncryptionKey).V1 implementation (
AesGcmCredentialEncryptor):
- KEK is loaded from configuration at startup (validated: must be exactly 32 bytes)
- DEK wrapping uses AES-256-GCM (KEK wraps DEK locally) instead of Key Vault
WrapKey/UnwrapKey- Blob format:
[1B version][12B DEK-wrap-nonce][16B DEK-wrap-tag][32B wrapped-DEK][12B data-nonce][16B data-tag][N bytes ciphertext](89 + N bytes)- DEK is zeroed via
CryptographicOperations.ZeroMemory()infinallyblocks- The KEK does exist in process memory (unlike Key Vault where it never leaves the HSM)
- A pre-generated dev-only key is provided in
appsettings.Development.json; production deployments must supply their own keyMigration path to Key Vault (V2): Replace
AesGcmCredentialEncryptorwith theEnvelopeEncryptionServicedescribed below. Since both schemes use AES-256-GCM for data encryption, migration requires only re-wrapping DEKs (unwrap with local KEK, re-wrap via Key Vault) — the ciphertext payload is unchanged. TheICredentialEncryptorinterface abstracts this swap.Security trade-off: The local KEK approach is simpler to deploy (no Azure dependency) but the KEK resides in process memory. An attacker with a memory dump could extract it. Key Vault eliminates this risk. For V1's deployment model (single-tenant, controlled infrastructure), this trade-off is acceptable.
All private key material is encrypted before storage in PostgreSQL using AES-256-GCM envelope encryption with Azure Key Vault wrap/unwrap operations. The master key (KEK) never leaves Key Vault.
Encryption flow (on key generation or import):
- Generate a random 256-bit Data Encryption Key (DEK) using
RandomNumberGenerator - Generate a random 96-bit IV
- Encrypt the private key material with the DEK using AES-256-GCM, producing ciphertext + authentication tag
- Call Azure Key Vault
WrapKeyto wrap the DEK with the KEK (RSA-OAEP-256). Key Vault returns the wrapped DEK and the KEK version used - Store the following as a single binary blob in
private_key_data:
┌────────────┬──────────────┬─────────┬──────────┬─────────────┬────────────┐
│ KEK Version│ Wrapped DEK │ IV │ Auth Tag │ Ciphertext │ Algorithm │
│ (36 bytes) │ (256 bytes) │(12 bytes)│(16 bytes)│ (variable) │ (2 bytes) │
└────────────┴──────────────┴─────────┴──────────┴─────────────┴────────────┘
Decryption flow (on private key use):
- Parse the stored blob to extract KEK version, wrapped DEK, IV, auth tag, and ciphertext
- Call Azure Key Vault
UnwrapKeywith the wrapped DEK and KEK version. Key Vault returns the plaintext DEK - Decrypt the ciphertext with the DEK using AES-256-GCM, verifying the authentication tag
- Use the plaintext private key for the requested operation
- Zero the plaintext DEK and private key from memory when done (
CryptographicOperations.ZeroMemory)
Key properties:
- Each entity gets its own random DEK — no DEK is ever reused across entities
- The KEK (RSA 2048-bit, Key Vault key, not a secret) is never exported or held in application memory
- Key Vault operations are remote HTTPS calls — latency is ~20ms per wrap/unwrap
- The wrapped DEK is opaque to the application — only Key Vault can unwrap it
- Storing the KEK version enables seamless rotation: new encryptions use the latest KEK version, old data references the version it was encrypted with
Master key rotation:
When the KEK is rotated in Key Vault (new version created), a background task iterates all encrypted entities and re-wraps their DEKs:
- Unwrap the DEK using the old KEK version (stored in the blob)
- Re-wrap the DEK using the new KEK version
- Update the stored blob with the new wrapped DEK and new KEK version
- The actual ciphertext is untouched — only the DEK wrapper changes
This means rotation does not require re-encrypting any private key material, only re-wrapping the DEKs.
What works in V1 without any code changes: When a new KEK version is created in Key Vault, all new encryptions automatically use the latest version (the CryptographyClient defaults to the latest version for WrapKeyAsync). All existing encrypted blobs continue to decrypt correctly because the blob stores the specific KEK version URI, and UnwrapKeyAsync uses that version. Key Vault retains all previous versions unless explicitly disabled. This means KEK rotation is immediately safe — the transition period where old and new versions coexist is handled natively by Key Vault's versioning.
What's planned for V2: An automated background task that iterates all encrypted entities and re-wraps their DEKs with the current KEK version. This is a key hygiene measure (ensures old KEK versions can eventually be disabled) but is not required for security — old versions remain usable until explicitly disabled in Key Vault.
If Azure Key Vault is unavailable at startup, Courier refuses to start rather than operating with unencrypted keys.
7.3.7 EnvelopeEncryptionService Implementation
The EnvelopeEncryptionService is the single code path for all at-rest encryption in Courier. Its design enforces a critical invariant: no key material — neither the KEK nor any unwrapped DEK — is ever cached in process memory beyond a single encrypt or decrypt operation.
// Courier.Infrastructure/Encryption/EnvelopeEncryptionService.cs
public sealed class EnvelopeEncryptionService : IEnvelopeEncryptionService
{
private readonly CryptographyClient _cryptoClient; // Azure SDK — calls Key Vault REST API
private readonly string _kekKeyName;
// NOTE: CryptographyClient does NOT download the key. It sends wrap/unwrap
// requests to Key Vault over HTTPS. The KEK plaintext never exists in this process.
public EnvelopeEncryptionService(CryptographyClient cryptoClient, string kekKeyName)
{
_cryptoClient = cryptoClient;
_kekKeyName = kekKeyName;
}
public async Task<EncryptedBlob> EncryptAsync(
ReadOnlyMemory<byte> plaintext, CancellationToken ct)
{
// 1. Generate a random DEK — unique per call, never reused
var dek = new byte[32]; // AES-256
RandomNumberGenerator.Fill(dek);
// 2. Generate a random IV
var iv = new byte[12]; // AES-GCM 96-bit
RandomNumberGenerator.Fill(iv);
// 3. Encrypt plaintext with the DEK
var ciphertext = new byte[plaintext.Length];
var tag = new byte[16]; // AES-GCM 128-bit auth tag
using (var aes = new AesGcm(dek, tagSizeInBytes: 16))
{
aes.Encrypt(iv, plaintext.Span, ciphertext, tag);
}
// 4. Wrap the DEK via Key Vault (remote HTTPS call)
// The KEK never leaves Key Vault. We send the DEK *to* Key Vault,
// Key Vault encrypts it with the KEK, and returns the wrapped blob.
var wrapResult = await _cryptoClient.WrapKeyAsync(
KeyWrapAlgorithm.RsaOaep256, dek, ct);
// 5. Zero the plaintext DEK *immediately* — it must not survive this method
CryptographicOperations.ZeroMemory(dek);
// 6. Return the composite blob (no secret material remains in memory)
return new EncryptedBlob(
KekVersion: wrapResult.KeyId, // Key Vault key version URI
WrappedDek: wrapResult.EncryptedKey,
Iv: iv,
AuthTag: tag,
Ciphertext: ciphertext);
}
public async Task<byte[]> DecryptAsync(EncryptedBlob blob, CancellationToken ct)
{
// 1. Unwrap the DEK via Key Vault (remote HTTPS call)
// Key Vault decrypts the wrapped DEK using the KEK version recorded in the blob.
var unwrapResult = await _cryptoClient.UnwrapKeyAsync(
KeyWrapAlgorithm.RsaOaep256, blob.WrappedDek, ct);
var dek = unwrapResult.Key;
try
{
// 2. Decrypt the ciphertext with the unwrapped DEK
var plaintext = new byte[blob.Ciphertext.Length];
using (var aes = new AesGcm(dek, tagSizeInBytes: 16))
{
aes.Decrypt(blob.Iv, blob.Ciphertext, blob.AuthTag, plaintext);
}
return plaintext;
}
finally
{
// 3. Zero the plaintext DEK — even if decryption fails
CryptographicOperations.ZeroMemory(dek);
}
}
}
public record EncryptedBlob(
string KekVersion, // Key Vault key version URI (e.g., "https://vault.azure.net/keys/courier-kek/abc123")
byte[] WrappedDek, // DEK encrypted by KEK (opaque — only Key Vault can unwrap)
byte[] Iv, // AES-GCM initialization vector (unique per operation)
byte[] AuthTag, // AES-GCM authentication tag (integrity verification)
byte[] Ciphertext); // Encrypted payload
What this code guarantees:
- The
CryptographyClientis an Azure SDK client that sends HTTPS requests to Key Vault. It never downloads the KEK. TheWrapKeyAsync/UnwrapKeyAsyncmethods send the DEK to Key Vault for server-side encryption/decryption using the KEK that exists only inside Key Vault's HSM or software boundary. - The plaintext DEK exists in process memory only for the duration of a single
EncryptAsyncorDecryptAsynccall, and is zeroed viaCryptographicOperations.ZeroMemoryin afinallyblock before the method returns. - No DEK is ever cached, pooled, or stored in a field. Every encrypt operation generates a fresh DEK. Every decrypt operation unwraps the DEK, uses it, and zeros it.
- The service is registered as a singleton (the
CryptographyClientis thread-safe and connection-pooled by the Azure SDK), but it holds no mutable state — no keys, no caches, no session data.
What this code explicitly does NOT do (anti-patterns that would compromise the security model):
| Anti-Pattern | Why It's Dangerous | How Courier Avoids It |
|---|---|---|
Cache the KEK in a private byte[] _masterKey field | Process memory dump exposes all encrypted data. Single point of compromise for the full process lifetime. | CryptographyClient never exposes the KEK. It's a remote API client, not a key holder. |
Cache unwrapped DEKs in an IMemoryCache or ConcurrentDictionary | DEK cache turns a single-entity breach into a multi-entity breach. Extends DEK exposure window from milliseconds to process lifetime. | DEKs are zeroed in finally blocks. No caching layer exists between Key Vault and the decrypt operation. |
Download the KEK at startup via GetKeyAsync and use it locally | Eliminates the Key Vault security boundary. Makes the application the HSM. | CryptographyClient is used (wraps the Key Vault Cryptography REST API), not KeyClient (which can download public key material but not private keys for HSM-backed keys). |
| Pre-decrypt all credentials at startup into an in-memory store | Creates a "golden store" in process memory — one dump exposes all credentials. | Credentials are decrypted on-demand at connection time and zeroed after use. |
Use AzureKeyVaultConfigurationProvider for the KEK | The config provider loads values into the .NET IConfiguration system as strings. The KEK is a key object, not a secret string — it cannot be loaded this way, and attempting to would mean holding key material in configuration memory. | KEK is accessed only via CryptographyClient.WrapKeyAsync / UnwrapKeyAsync. Application secrets (DB connection string, etc.) use the config provider. These are different systems serving different purposes. |
Latency trade-off: Every encrypt or decrypt operation incurs a Key Vault round-trip (~20ms). For Courier's workload (decrypt a credential or private key at job execution time, not on every API request), this is acceptable. A job that decrypts 3 credentials and 1 PGP key adds ~80ms of Key Vault overhead to its startup — negligible against a multi-minute file transfer. The NFR target (Section 2.11) accounts for this overhead.
DI registration:
// Courier.Infrastructure/DependencyInjection.cs
services.AddSingleton(sp =>
{
var vaultUri = sp.GetRequiredService<IConfiguration>()["KeyVault:Uri"]!;
var keyName = sp.GetRequiredService<IConfiguration>()["KeyVault:KekKeyName"]!;
var credential = new DefaultAzureCredential();
// CryptographyClient — performs remote wrap/unwrap. Does NOT download the key.
var cryptoClient = new CryptographyClient(
new Uri($"{vaultUri}/keys/{keyName}"), credential);
return new EnvelopeEncryptionService(cryptoClient, keyName);
});
Note on CryptographyClient vs KeyClient: The Azure SDK has two clients for Key Vault keys. KeyClient manages key lifecycle (create, rotate, delete, get metadata). CryptographyClient performs cryptographic operations (wrap, unwrap, sign, verify) without ever exposing the private key material. Courier uses CryptographyClient for envelope encryption and KeyClient only for the KEK rotation background task (to create new key versions and read metadata). Neither client can export an HSM-backed private key.
7.4 Encryption Operations
7.4.1 Single-Recipient Encryption
The standard flow for encrypting a file:
- Resolve the recipient's public key from the Key Store by ID
- Validate the key is in
ActiveorExpiringstatus - Open a streaming pipeline:
FileStream (input) → PGP EncryptionStream → FileStream (output) - Write output in the configured format (armored or binary)
- Report progress via
IProgress<CryptoProgress>at regular intervals
7.4.2 Multi-Recipient Encryption
When multiple recipient key IDs are provided, the file is encrypted such that any recipient can decrypt it with their own private key. BouncyCastle handles this natively by adding multiple public key encrypted session keys to the PGP message.
All recipient keys must be Active or Expiring. If any key is Retired, Revoked, or Deleted, the operation fails with a descriptive error listing the invalid keys.
7.4.3 Sign-Then-Encrypt
A common workflow is to sign a file with the sender's private key and then encrypt it for the recipient. This is supported as a single step configuration:
{
"step_type": "pgp.encrypt",
"config": {
"recipient_key_ids": ["<recipient-uuid>"],
"signing_key_id": "<sender-private-key-uuid>",
"output_format": "armored"
}
}
The engine signs the data first (using the private key), then encrypts the signed payload. The recipient decrypts first, then verifies the signature — both operations are automatic when verify_signature: true is set on the decrypt step.
7.5 Decryption Operations
Decryption resolves the private key from the Key Store, handles passphrase unlocking transparently, and streams the decrypted output:
- Load the encrypted file as a stream
- Resolve the private key and decrypt it via envelope decryption (Key Vault unwraps the DEK, DEK decrypts the key material)
- If the private key has a passphrase, unlock it
- Stream decrypt:
FileStream (encrypted) → PGP DecryptionStream → FileStream (output) - If
verify_signatureis enabled and the message was signed, verify the signature against known public keys in the store - Write the
VerifyResulttoJobContextfor downstream decision-making
If decryption fails (wrong key, corrupted data), the step fails with a specific error type that the job's failure policy can act on.
7.6 Signing & Verification
7.6.1 Signature Modes
| Mode | Output | Use Case |
|---|---|---|
Detached | Separate .sig file alongside the original | Original file must remain unmodified |
Inline | Signed data wrapping the original content | Single file with embedded signature |
Clearsign | Human-readable text with ASCII signature block | Text files, emails |
The signature mode is configured per step. Detached is the default and most common for file transfer workflows.
7.6.2 Verification
Signature verification can be performed as a standalone step (pgp.verify) or as part of decryption. The result is a typed VerifyResult written to the JobContext:
- Valid: Signature matches, signer key is
ActiveorExpiring - Invalid: Signature does not match the data (file may be tampered)
- UnknownSigner: Signature is cryptographically valid but the signer's public key is not in the Key Store
- ExpiredKey: Signer's key has expired (signature may still be trustworthy depending on policy)
- RevokedKey: Signer's key has been revoked (signature should not be trusted)
Downstream steps can reference the verification status in their configuration to branch behavior — for example, a file.move step could be configured to only execute if verify_status == Valid.
7.7 Streaming Crypto for Large Files
All cryptographic operations use BouncyCastle's streaming API. The pipeline for a large file encryption:
FileStream (source, 80KB buffer)
→ [Optional] SignatureGenerationStream
→ PgpEncryptedDataGenerator (streaming)
→ [Optional] ArmoredOutputStream
→ FileStream (output, 80KB buffer)
Memory usage is bounded to approximately 2× the buffer size regardless of file size. For 6–10 GB files, the bottleneck is disk I/O, not memory. Progress is reported every 10MB processed, consistent with the compression system.
7.8 Key Rotation Management
A background service (KeyExpirationService) runs daily and performs the following:
- Scan all keys with
Activestatus and anexpires_atdate - Transition keys within the warning window (default: 30 days) to
Expiringstatus - Emit
KeyExpiringSoondomain events for each newly transitioned key - Transition keys past their expiration date to
Retiredstatus - Emit
KeyExpireddomain events for each newly retired key
In V1, these domain events are recorded in the key audit log. In V2, event subscribers will deliver notifications via email, Slack, or UI alerts. The event infrastructure mirrors the Job Engine's notification hooks (Section 5.17), ensuring consistent patterns across the system.
Optional auto-generation: A future enhancement could automatically generate a replacement key pair when a key enters Expiring status. The new key would be created with the same parameters (algorithm, size, user ID) and linked to the expiring key as its successor. This is not in V1 scope but the schema accommodates it with a nullable successor_key_id column.
7.9 Key Audit Log
All key operations are recorded in the key_audit_log table:
| Column | Type | Description |
|---|---|---|
id | UUID | Audit record ID |
key_id | UUID | FK to the key |
operation | ENUM | Generated, Imported, Exported, UsedForEncrypt, UsedForDecrypt, UsedForSign, UsedForVerify, StatusChanged, Deleted |
performed_by | TEXT | User or system service that performed the operation |
performed_at | TIMESTAMP | When the operation occurred |
job_execution_id | UUID | FK to job execution if the operation was part of a job |
details | JSONB | Additional context (e.g., old status → new status, export format, error details) |
This log is append-only and never modified. It provides full traceability for compliance requirements — you can answer questions like "which jobs used this key in the last 90 days" or "who exported this private key and when."
7.10 ECC Future-Proofing
While V1 supports RSA only, the system is designed for ECC support without breaking changes:
- The
algorithmenum on the key entity includes placeholder values forECC_CURVE25519,ECC_P256, andECC_P384 - The
ICryptoProviderinterface is algorithm-agnostic — it receives key IDs and resolves the correct BouncyCastle implementation at runtime - BouncyCastle already supports ECC key generation and OpenPGP operations with elliptic curves
- Adding ECC requires: implementing ECC key generation, adding ECC-specific validation rules, and testing interoperability with common PGP clients (GPG, Kleopatra)
No database migration, interface changes, or job configuration changes will be needed.