NIS2 Incident Report Envelope v1
Contract ID: CONTRACT-NIS2-INCIDENT-REPORT-ENVELOPE-V1-028 Status: Draft Owner: Notify platform Contract version: nis2-incident-report-envelope.v1
Part of the Stella Ops Contracts index. Audience: Notify implementers building the Member-State CSIRT connector, and operators who configure NIS2 reporting channels. This envelope is a deterministic operator submission aid; it does not make Stella Ops the regulated entity (see Purpose).
Runtime library: src/Notify/__Libraries/StellaOps.Notify.Connectors.NCS/
Adjacent tests: src/Notify/__Tests/StellaOps.Notify.Connectors.NCS.Tests/
Notify plugin id: stellaops.notify.connector.ncs (display name “StellaOps NIS2 Member-State CSIRT Connector”; version 0.1.0-alpha; requiresRestart: true). The connector binds to NotifyChannels whose purpose is nis2-csirt (NotifyChannelPurpose.Nis2Csirt) and whose type is custom (the plugin metadata default delivery mode is signed-json-webhook).
Purpose
This contract defines Stella’s local, deterministic NIS2 Article 23 incident report envelope for customer/operator handoff to Member-State CSIRT/NCA channels. It is an operator submission aid. It does not make Stella the regulated entity and it does not hardcode national portal endpoints.
Versioning
The version strings below are the canonical constants on Nis2CsirtConstants (StellaOps.Notify.Connectors.NCS). The connector rejects any input whose contractVersion / localEnvelopeSchemaVersion does not match these exact values (Nis2CsirtValidator.RequireExact).
- Contract version (
Nis2CsirtConstants.ContractVersion):nis2-incident-report-envelope.v1 - Local envelope schema version (
Nis2CsirtConstants.LocalEnvelopeSchemaVersion):nis2-csirt-local-envelope-v1 - Local validation rules (
Nis2CsirtConstants.LocalValidationRuleSetVersion):nis2-csirt-local-validation-v1 - Envelope media type (
Nis2CsirtConstants.EnvelopeMediaType):application/vnd.stellaops.nis2-csirt-report.v1+json - Schema pinning policy: Stella minor releases pin this local envelope and the connector adapter behavior. Official Member-State portal schemas can move from
blockedonly after the exact contract package is vendored, license-reviewed, and covered by local tests.
Report Types
reportType is emitted as a stable string id (Nis2CsirtValidator.ReportTypeId) from the Nis2CsirtReportType enum:
early_warning_24h(EarlyWarning24h)incident_notification_72h(IncidentNotification72h)final_report_1m(FinalReport1m)
Envelope Fields
The connector writes compact JSON (Utf8JsonWriter, not indented) with stable property order and arrays sorted ordinally. Field names and order are fixed by Nis2CsirtEnvelopeBuilder.BuildEnvelope; the envelope is sha256-hashed (sha256:<64 lowercase hex>) and the hash is re-verified by Nis2CsirtValidator.ValidatePackage before signing. Top-level properties, in emit order:
contractVersion,localEnvelopeSchemaVersion,reportType,reportIdtenantId,channelId,memberStateCode,authorityDisplayName,profileIdacceptedFormat: one ofsigned-json-webhook,csaf,webform,pgp-email(Nis2CsirtValidator.AcceptedFormatId)generatedAtas UTC ISO-8601 with milliseconds (yyyy-MM-ddTHH:mm:ss.fffZ)operatorApprovalRefreporter:legalEntityName,countryCode(ISO 3166-1 alpha-2),contactRef,complianceProfileRefincident:incidentId,title,summary,severity,status,startedAt, then optional (nullable)detectedAt,closedAt,suspectedCause,impactSummary,crossBorderImpactSummary,rootCauseSummary,remediationSummaryclassification:significantImpact(bool),criteria(array),rationaletimeline:timelineRef, thenopenedAt,earlyWarningDueAt(24-hour),notificationDueAt(72-hour),finalReportDueAt(one-month), andpreviousTransmissionHashes(array ofsha256:...)affectedServices(each:serviceId,name,category,impactSummary,assetRefs),indicators(each:indicatorId,type,value, optionalfirstSeenAt),actions(each:actionId,description,status, optionalperformedAt)attestationRefs,evidenceRefs(each must be non-empty)localValidation: object withstatusandruleSetVersion(nis2-csirt-local-validation-v1)memberStatePortalContractValidation: object withstatus,reason,evidence(array)
Severity, status, indicator type, action status, and service category values are lower-cased during normalization. Optional timestamp/string fields are written as JSON null when absent rather than omitted.
Validation is report-type aware: 72h and final reports additionally require at least one classification.criteria entry, a classification.rationale, at least one affectedServices entry, and at least one actions entry. Final reports must also include incident.closedAt, incident.rootCauseSummary, incident.remediationSummary, and at least one timeline.previousTransmissionHashes entry (each a valid sha256:<64 hex>).
Delivery Modes
The connector resolves one of three delivery modes (Nis2CsirtChannelOptionsParser.ResolveDeliveryMode) from the channel deliveryMode property, falling back to the channel type (Email -> pgp-email, Webhook -> signed-json-webhook, otherwise member-state-portal-stub):
member-state-portal-stub(MemberStatePortalStubDeliveryMode): requires the member state to be DE/FR/NL and an absolute HTTPSingestionUrl; honorsrequiredHeaders. Submitted viaINis2CsirtSubmissionClientwhen one is wired; otherwise the request is prepared but not sent.signed-json-webhook(SignedJsonWebhookDeliveryMode): requires an absolute HTTPSwebhookUrl.pgp-email(PgpEmailDeliveryMode): requires a valid operator email, a full OpenPGPpgpRecipientFingerprint(40 or 64 hex), and apgpPublicKeyRef.
Audit event types are emitted per outcome (Nis2CsirtDeliveryAuditEvent), e.g. nis2.csirt.portal_stub.submitted / .prepared, nis2.csirt.signed_webhook.submitted / .prepared, nis2.csirt.pgp_email.encrypted / .blocked.
Signing And Handoff
The envelope is signed through INis2CsirtReportSigner. In notify-web the production adapter is the Notify-owned HTTP client Nis2CsirtSignerApiClient, which calls POST /api/v1/signer/sign/dsse; notify-web does not compose Signer’s pipeline, infrastructure, key-management, or local key seeder. The request uses KMS signing mode and predicate type:
https://stellaops.org/predicates/nis2-csirt-report/v1
The resulting DSSE envelope uses payload type application/vnd.in-toto+json and the signature profile id signer-api-in-toto. The client accepts a success only when the base64url DSSE payload and every signature are bounded and well-formed and the decoded in-toto statement has the exact predicate type, predicate JSON, single subject name, and subject sha256 requested. Signer returns the DSSE envelope JSON, its sha256, the signed subject sha256, the signature profile, and the optional Signer audit id and key id.
Notify binds a stable Idempotency-Key to the tenant, normalized report identity, and canonical signing operation. Signer durably stores the exact status/content-type/body response and replays it byte-for-byte after a response is lost. A key reused for a different operation returns 409; an in-flight operation returns 425. The caller subject and non-secret PoE entitlement identity participate in the operation binding, while bearer, DPoP, and PoE credential values do not. A stale Signer lease may take over; if the first owner signed before it crashed but had not stored the response, takeover may sign and audit again. This boundary is therefore at-least-once across that narrow crash window, not an exactly-once cryptographic-operation claim.
Notify persists work before calling Signer in the own-database notify.nis2_csirt_signing_work table and advances the recoverable stages prepared -> signed -> submission_recorded -> timeline_recorded -> terminal. Transient Authority/Signer/submission failures remain pending with deterministic capped backoff; permanent refusals become terminally refused. The signed result is checkpointed before submission, the submission receipt before the timeline, and the timeline before the compatible terminal nis2-csirt-delivery-ledger.v1 row. Active work is not eligible for retention pruning; only terminal/refused replay records have the P13 disposition. The opaque cross-tenant due index contains identity and time only. The currently provisioned Notify database owner has BYPASSRLS, so application SQL retains explicit tenant predicates but the database does not enforce tenant isolation against that login; a supplemental non-bypass role proves the FORCE-RLS policy.
This Notify-to-Signer composition is default-off and not approved for live enablement yet. It remains gated on Notify NTF-9 (the transitional channel, terminal-delivery, and Findings-ledger paths still use legacy/foreign persistence), a dedicated Authority client enrolled for the tenant with exact audience signer and scope signer:sign, matching Signer PoE/key enrollment, and cryptographic DPoP/access-token sender-binding validation through a gateway or corrected direct endpoint. The direct endpoint currently validates only the proof’s structural shape, and the Auth.Client token request is not DPoP-bound. Production also requires HTTPS; locally permitted HTTP is not live approval. No live enrollment, grant, key, configuration, deployment, or restart is part of this contract update.
Signed-webhook (and portal-stub) handoff wraps the envelope and DSSE envelope in a body with Content-Type application/vnd.stellaops.nis2-csirt-signed-handoff.v1+json (SignedHandoffMediaType) and sends Stella headers. Member-State profiles declare five required headers; the connector adds two more at submission time (X-StellaOps-Body-SHA256, X-StellaOps-Signature-Profile) plus Content-Type. Operator-configured header.* properties are merged in first. Full header set:
X-StellaOps-NIS2-Contract-VersionX-StellaOps-NIS2-Member-StateX-StellaOps-Report-IdX-StellaOps-Envelope-SHA256X-StellaOps-DSSE-SHA256X-StellaOps-Body-SHA256(added at submission)X-StellaOps-Signature-Profile(added at submission)
Member-State Profiles
Initial local stub profiles exist for (Nis2CsirtMemberStateProfiles):
DE: BSI/CERT-Bund profile (profileIdnis2-csirt-de-bsi-cert-bund-local-stub-v1)FR: ANSSI/CERT-FR profile (profileIdnis2-csirt-fr-anssi-cert-fr-local-stub-v1)NL: NCSC-NL profile (profileIdnis2-csirt-nl-ncsc-nl-local-stub-v1)
Any other member state resolves to a generic Operator-configured NIS2 CSIRT/NCA profile (profileId nis2-csirt-generic-operator-configured-v1), but member-state-portal-stub delivery is restricted to DE/FR/NL.
These profiles intentionally do not contain live portal URLs or official API contracts. Operators configure ingestionUrl, acceptedFormat, and any portal-required headers per tenant/channel.
memberStatePortalContractValidation.status is blocked (Nis2CsirtConstants.PortalContractValidationStatus) until exact local portal contracts are available, and the connector’s ValidatePackage step enforces that the envelope’s status stays blocked.
Channel Configuration
Per-tenant/channel settings are read from NotifyChannel.Config.Properties (Nis2CsirtChannelOptionsParser). Each key is accepted either bare (e.g. memberStateCode) or prefixed with notify.channel.nis2. (e.g. notify.channel.nis2.memberStateCode):
deliveryMode:member-state-portal-stub|signed-json-webhook|pgp-email(defaults from channel type if unset)memberStateCode(ormemberState): ISO 3166-1 alpha-2acceptedFormat: parsed leniently —signed-json/signed-json-webhook/webhook/empty ->signed-json-webhook;csaf;webform/form->webform;pgp/pgp-email/email->pgp-emailingestionUrlorwebhookUrl(falls back to channelEndpoint/Target): must be an absolute HTTPS URI for portal-stub/webhook delivery, with no user-info, query, or fragmentemailAddress(falls back to channelTarget): operator recipient emailrequiredHeaders: comma/semicolon/pipe-separated header names that must be present (portal-stub mode)header.<Name>(ornotify.channel.nis2.header.<Name>): operator-supplied outbound header values. These dynamic slots are secret-bearing: write and hosted migration seal inline values, and each submission attempt resolves them only in memory. An unresolved secret reference fails closed and is never sent literally.pgpRecipientFingerprint(orpgpFingerprint): full OpenPGP fingerprint (40 or 64 hex, normalized upper-case)pgpPublicKeyRef: reference resolved byINis2PgpPublicKeyResolver
PGP Email Status
PGP-email fallback is available through the approved crypto-boundary contract (OpenPgpEncryptionProviderContract.ContractVersion; see OpenPGP Encryption Provider v1):
StellaOps.Cryptography.OpenPgpEncryptionProvider/v1
The boundary is the INis2PgpEmailEncryptor interface; the production adapter is Nis2OpenPgpEmailEncryptor, which wraps IOpenPgpEncryptionProvider (StellaOps.Cryptography.Plugin.OpenPgp) and an INis2PgpPublicKeyResolver. Notify resolves operator-supplied public key material by pgpPublicKeyRef, validates the full recipient fingerprint, encrypts the signed handoff through the provider, and records the ciphertext SHA-256, provider id, recipient fingerprint, and selected encryption-key fingerprint in delivery metadata.
PGP-email preparation carries a status of blocked, prepared, or encrypted (Nis2CsirtConstants.PgpEncryptionStatus*). The connector fails closed (status blocked, delivery status Failed) if no INis2PgpEmailEncryptor is provided; the adapter additionally throws if the key reference cannot be resolved (resolved ref must equal the requested pgpPublicKeyRef), if the recipient fingerprint does not match the key material, or if the provider rejects the key for expiry, revocation, or missing encryption-key capability. Notify code never calls OpenPGP libraries or shells out to gpg directly.
Delivery Ledger Metadata
A successful or prepared handoff produces a NotifyDelivery ledger entry with metadata keys prefixed nis2. (Nis2CsirtOutboundChannel.BuildDeliveryLedgerEntry), including nis2.acceptedFormat, nis2.audit.eventType, nis2.contractVersion, nis2.deliveryMode, nis2.dsse.sha256, nis2.envelope.sha256, nis2.localSchemaVersion, nis2.memberState, nis2.portalContractValidation, nis2.reportId, nis2.reportType, and nis2.signatureProfile. Submission, signer, and PGP outcomes add conditional keys (e.g. nis2.submission.receiptId, nis2.signer.auditId, nis2.signer.keyIdHash, nis2.pgp.status, nis2.pgp.ciphertextSha256, nis2.pgp.recipientFingerprint). Endpoint and key ids are stored as one-way references derived from a credential-free endpoint origin and receiver-controlled response hash, never as raw endpoint paths or receiver text. Delivery status is Failed for a blocked PGP attempt, Queued when a submission request is prepared but no submission client returned a receipt, and Sent once a receipt is recorded.
For submit=true, a successful receipt checkpoint prevents resubmission on ordinary retries. A crash or timeout after the external receiver accepted the request but before Notify received/persisted that receipt is irreducibly ambiguous because no verified portal idempotency or receipt-recovery contract is available; retry may submit again. This is an operator-visible at-least-once handoff boundary, not a never-duplicate claim.
Offline Expectations
All tests must run without internet access, live national portals, SMTP, or webhook endpoints. Current offline proof covers:
- deterministic envelope byte stability
- DE/FR/NL stub submission contracts
- signed-webhook hash and DSSE verification with a local test signer
- production WebService signed-webhook submission path with a local HTTPS stand-in receiver, offline DSSE/body verification, and Notify delivery repository readback; this is not external CSIRT/NCA delivery evidence
- fail-closed PGP-email blocker when no provider is wired
- PGP-email encryption, ciphertext hash evidence, local decrypt, and integrity verification with generated local test keys
- final-report validation for previous transmission hashes
