OpenPGP Encryption Provider v1
The Stella Ops crypto-boundary primitive for OpenPGP public-key encryption: an in-process provider that operator-controlled email handoff workflows (such as NIS2 CSIRT fallback delivery) call instead of touching OpenPGP libraries or gpg directly. This contract is for implementers wiring that boundary and for reviewers verifying its fail-closed validation guarantees.
Owner: Security & Cryptography Guild
Runtime provider: src/Cryptography/StellaOps.Cryptography.Plugin.OpenPgp/
Contract name: StellaOps.Cryptography.OpenPgpEncryptionProvider/v1
Provider id: stellaops.openpgp.bouncycastle.v1
Both constants are defined in OpenPgpEncryptionProviderContract (ContractVersion and ProviderId) and are re-declared verbatim in the plugin manifest (plugin.yaml: pluginId == contract name, providerId == provider id).
Provider Surface
The contract is a crypto-boundary library primitive. It exposes no HTTP route, CLI command, OAuth scope, or persistence schema; it is consumed in-process via the IOpenPgpEncryptionProvider interface:
string ContractVersion { get; }— returns the contract name above.string ProviderId { get; }— returns the provider id above.ValueTask<OpenPgpEncryptionResult> EncryptAsync(OpenPgpEncryptionRequest request, CancellationToken cancellationToken).
The shipping implementation is BouncyCastleOpenPgpEncryptionProvider, built on BouncyCastle.Cryptography.
Purpose
This contract defines the StellaOps crypto-boundary primitive for OpenPGP public-key encryption used by operator-controlled email handoff workflows such as NIS2 CSIRT fallback delivery. Notify and other product modules must consume this provider boundary instead of calling OpenPGP libraries or shelling out to gpg directly.
Inputs
The request DTO is OpenPgpEncryptionRequest (all fields are required):
RecipientFingerprint— full recipient OpenPGP fingerprint. The provider normalizes it by stripping non-hex characters and upper-casing; the normalized value must be exactly 40 hex chars (v4) or 64 hex chars (v5), otherwiseopenpgp.recipient_fingerprint_invalidis thrown.ArmoredPublicKey— ASCII-armored OpenPGP public key material supplied by the operator or resolved from an operator-controlled key reference.Plaintext—ReadOnlyMemory<byte>payload. Must be non-empty.FileName— literal-data file name. Must be non-blank; it isTrim()-ed before use and written as an OpenPGP binary literal with a fixedDateTime.UnixEpochtimestamp, so the literal-data header is deterministic.EvaluationTimeUtc—DateTimeOffsetsupplied by the caller for expiry checks (converted to UTC internally). Must not bedefault. The provider does not read wall-clock time directly.
Required Validation
The provider fails closed unless all checks pass. Every failure surfaces as an OpenPgpEncryptionException carrying a stable ErrorCode:
- The supplied fingerprint matches a primary key or encryption subkey in the supplied public key ring. (No match across all key rings yields
openpgp.encryption_key_unavailable; the message appends the per-candidate rejection reasons accumulated during selection.) - The primary key is not revoked (
openpgp.primary_key_revoked) and has not expired (openpgp.primary_key_expired) at the caller-supplied evaluation time. - The selected encryption key is not revoked (
openpgp.encryption_key_revoked) and has not expired (openpgp.encryption_key_expired) at the caller-supplied evaluation time. - The selected key is an OpenPGP encryption key (
openpgp.encryption_key_capability_missingotherwise). - When OpenPGP key flags are present, the selected key advertises
CanEncryptCommunicationsorCanEncryptStorage(openpgp.encryption_key_flags_missingotherwise). If no key-flags subpacket is present at all, the flag check passes (it is only enforced when flags are declared); theEncryptionKeyFlagsCheckedevidence field records whether a flags subpacket was actually inspected.
Note: when a candidate encryption key fails revocation, expiry, capability, or flag checks, that candidate is skipped and selection continues to the next matching key; only if no candidate survives is openpgp.encryption_key_unavailable thrown (with the collected reasons). The adjacent tests exercise the fingerprint-mismatch and missing-encryption-flag paths via this aggregate error code, and the expired-primary path via openpgp.primary_key_expired.
Error codes
The full set of ErrorCode values emitted by the v1 provider:
| Error code | Condition |
|---|---|
openpgp.recipient_fingerprint_invalid | Normalized fingerprint is not 40 or 64 hex chars. |
openpgp.public_key_missing | ArmoredPublicKey is null/blank. |
openpgp.plaintext_empty | Plaintext is empty. |
openpgp.literal_filename_missing | FileName is null/blank. |
openpgp.evaluation_time_missing | EvaluationTimeUtc is default. |
openpgp.public_key_parse_failed | Armored key material could not be parsed. |
openpgp.primary_key_revoked | Matched primary key is revoked. |
openpgp.primary_key_expired | Matched primary key expired at evaluation time. |
openpgp.encryption_key_revoked | Candidate encryption key is revoked. |
openpgp.encryption_key_expired | Candidate encryption key expired at evaluation time. |
openpgp.encryption_key_capability_missing | Candidate key is not an encryption key. |
openpgp.encryption_key_flags_missing | Candidate key declares key flags but lacks an encrypt flag. |
openpgp.encryption_key_unavailable | No candidate key satisfied all checks. |
Output Evidence
Successful encryption returns an OpenPgpEncryptionResult with the following fields:
AsciiArmoredMessage— ASCII-armored PGP message (-----BEGIN PGP MESSAGE-----).Sha256— SHA-256 of the exact ASCII-armored ciphertext, formatted assha256:<lowercase-hex>(the digest carries thesha256:prefix; downstream NIS2 consumers rely on this prefix).ProviderId—stellaops.openpgp.bouncycastle.v1.ContractVersion— the contract name.RecipientFingerprint— the normalized recipient fingerprint.EncryptionKeyFingerprint— selected encryption-key fingerprint (uppercase hex).EncryptionKeyId— selected encryption-key id (16-char uppercase hex).SymmetricAlgorithm— fixed labelAES-256(BouncyCastleSymmetricKeyAlgorithmTag.Aes256, with integrity protection enabled).CompressionAlgorithm— fixed labelZIP(CompressionAlgorithmTag.Zip).KeyValidation— anOpenPgpKeyValidationEvidencerecord capturing primary and encryption-key fingerprints, encryption-key id, creation times, optional expiry times, revocation flags, theEncryptionKeyAdvertisesEncryptionflag, theEncryptionKeyFlagsCheckedflag, plus the provider id and contract version.
OpenPGP encryption is intentionally randomized; the ciphertext itself is not byte-stable across runs. The audit-grade deterministic evidence is the sha256:-prefixed digest computed over the exact ciphertext produced for that handoff.
Offline Expectations
Tests must run without network, SMTP, live keyservers, or external gpg. Adjacent coverage generates local test key rings, encrypts through the provider, decrypts with the generated secret key, verifies the integrity packet, and asserts fingerprint mismatch, expiry, and missing encryption-key-flag failures.
Current tests:
src/Cryptography/__Tests/StellaOps.Cryptography.Tests/OpenPgp/BouncyCastleOpenPgpEncryptionProviderTests.cssrc/Notify/__Tests/StellaOps.Notify.Connectors.NCS.Tests/Nis2CsirtOutboundChannelTests.cs
Plugin Manifest
The provider ships as a Cryptography plugin. plugin.yaml declares:
pluginId: StellaOps.Cryptography.OpenPgpEncryptionProvider/v1providerId: stellaops.openpgp.bouncycastle.v1displayName: StellaOps OpenPGP Encryption Provider,version: 1.0.0capabilities: [openpgp-public-key-email-encryption]requirements:recipient-fingerprint-validation, key-expiry-check, key-revocation-check, encryption-key-capability-check, ascii-armored-output, ciphertext-sha256-evidenceoffline: true,testOnly: false
Consumers
The NIS2 CSIRT outbound connector consumes this boundary via Nis2OpenPgpEmailEncryptor (src/Notify/__Libraries/StellaOps.Notify.Connectors.NCS/), which resolves operator-controlled key material through an INis2PgpPublicKeyResolver, builds the deterministic handoff JSON, calls IOpenPgpEncryptionProvider.EncryptAsync, and forwards the armored message, sha256:-prefixed digest, contract version, provider id, recipient fingerprint, and encryption-key fingerprint into Nis2CsirtEncryptedPgpEmail. Connectors and other product modules must consume this provider boundary instead of calling OpenPGP libraries or shelling out to gpg directly.
