From 649f99c75eff4fbd5e15ec591443a2a16cf90c75 Mon Sep 17 00:00:00 2001 From: jillesvano Date: Fri, 24 Apr 2026 15:58:05 +0200 Subject: [PATCH 01/14] First draft --- ...ty-access-and-registration-certificates.md | 592 ++++++++++++++++++ 1 file changed, 592 insertions(+) create mode 100644 conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md diff --git a/conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md b/conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md new file mode 100644 index 00000000..a183bd7a --- /dev/null +++ b/conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md @@ -0,0 +1,592 @@ +# WE BUILD - Conformance Specification: Issuance of Relying Party Access and Registration Certificates + +Version 0.8 / Draft +Date: 23 April 2026 + +## Table of Contents + +- [1. Introduction](#1-introduction) +- [2. Scope](#2-scope) +- [3. Normative Language](#3-normative-language) +- [4. Roles and Components](#4-roles-and-components) +- [5. Protocol Overview](#5-protocol-overview) + - [5.1 ACME Profile for WRPAC Issuance](#51-acme-profile-for-wrpac-issuance) + - [5.2 ACME Resource Model](#52-acme-resource-model) + - [5.3 WRPAC Identifier Type](#53-wrpac-identifier-type) + - [5.4 Challenge Type: registrar-api-01](#54-challenge-type-registrar-api-01) + - [5.5 EBW-based Account Binding](#55-ebw-based-account-binding) + - [5.6 Mapping to WE BUILD Blueprint Issuance Process](#56-mapping-to-we-build-blueprint-issuance-process) +- [6. High-level Flows](#8-high-level-flows) + - [6.1 Directory Discovery](#81-directory-discovery) + - [6.2 Account Creation with EBW Authentication](#82-account-creation-with-ebw-authentication) + - [6.3 Order Creation](#83-order-creation) + - [6.4 Authorization and Challenge Validation](#84-authorization-and-challenge-validation) + - [6.5 Order Finalization](#85-order-finalization) + - [6.6 Certificate Download](#86-certificate-download) + - [6.7 Certificate Revocation](#87-certificate-revocation) +- [7. Normative Requirements](#9-normative-requirements) + - [7.1 Common Requirements](#91-common-requirements) + - [7.2 ACME Server (CA / RA)](#92-acme-server-ca--ra) + - [7.3 ACME Client (WRP / EBW)](#93-acme-client-wrp--ebw) + - [7.4 Certificate Profile](#94-certificate-profile) + - [7.5 Certificate Transparency](#95-certificate-transparency) + - [7.6 Revocation](#96-revocation) + - [7.7 Trusted List Integration](#97-trusted-list-integration) + - [7.8 WE BUILD RP Lists](#98-we-build-rp-lists) +- [8. Interface Definitions](#10-interface-definitions) + - [8.1 ACME Directory](#101-acme-directory) + - [8.2 Account Management with EBW/POA](#102-account-management-with-ebwpoa) + - [8.3 Order Lifecycle](#103-order-lifecycle) + - [8.4 Authorization and Challenge](#104-authorization-and-challenge) + - [8.5 Finalize and Certificate](#105-finalize-and-certificate) + - [8.6 Revocation](#106-revocation) +- [9. Conformance](#11-conformance) +- [References](#references) + +--- + +# 1. Introduction + +This document defines the **WE BUILD Consortium Conformance Specification (CS)** for the issuance of Wallet-Relying Party Access Certificates (WRPACs) and, where applicable, Wallet-Relying Party Registration Certificates (WRPRCs) within the European Digital Identity Wallet ecosystem, using a protocol based on the Automatic Certificate Management Environment (ACME) as defined in RFC 8555 [1]. + +It profiles: + +* Commission Implementing Regulation (EU) 2025/848 [2], in particular Article 7 and Annex IV +* ETSI TS 119 411-8 v1.1.1 [3] — Access Certificate Policy for EUDI Wallet Relying Parties +* ETSI TS 119 475 v1.2.1 [4] — Relying party attributes supporting EUDI Wallet user's authorisation decisions, in particular Annex D1 (Issuance process) +* WE BUILD WP4 Architecture Blueprint [5] — RPAC/RPRC documentation and issuance process +* IETF RFC 8555 [1] — Automatic Certificate Management Environment (ACME) + +This specification positions the ACME protocol as the **technical implementation** of the issuance process defined in the WE BUILD Blueprint [5]. Section 7 provides an explicit mapping between the Blueprint's 11-step issuance flow and the ACME protocol operations. + +This specification focuses **only on direct issuance** of WRPACs to registered Wallet-Relying Parties. The ACME flow is extended with EBW-based authentication and POA verification to align with the Blueprint's requirements for user authentication via European Business Wallets. This document is used to build the WE BUILD Interoperability Test Bed Plus (ITB+) [6]. + +> [!IMPORTANT] +> CS-RPAC_01: This specification is intended for **interoperability testing only**, not for production deployment. Its purpose is to validate the technical feasibility of ACME-based WRPAC issuance and to establish interoperability between independent implementations. Production deployments will require additional security hardening, policy alignment, and conformity assessment beyond the scope of this document. + +# 2. Scope + +This specification defines: + +* An ACME protocol profile (based on RFC 8555) for the automated issuance of X.509-based Wallet-Relying Party Access Certificates, aligned with the WE BUILD Blueprint issuance process +* A custom ACME identifier type (`wrp-id`) for WRP identifiers +* A custom ACME challenge type (`registrar-api-01`) for verifying WRP authorization against WE BUILD RP Lists +* An EBW-based account binding mechanism for user authentication via European Business Wallets +* Requirements for: + * ACME Servers (TSP Certificate Authorities and Registration Authorities) + * ACME Clients (Wallet-Relying Parties using their EBW) +* Protocol flows for: + * Direct WRPAC issuance to an authorized WRP (with optional co-issuance of WRPRC) + * Certificate revocation + +This specification does **not** cover: + +* Intermediary or multi-party issuance +* Standalone WRPRC issuance (covered in a separate CS) +* Production deployment requirements (conformity assessment, CAB audits, national policy extensions) +* Proximity use cases for certificate presentation + +# 3. Normative Language + +The keywords MUST, MUST NOT, REQUIRED, SHALL, SHOULD, SHOULD NOT, RECOMMENDED, MAY and OPTIONAL are to be interpreted as commonly used in technical specifications. + +# 4. Roles and Components + +This specification uses the following roles, mapped to both the ACME protocol and the WE BUILD Blueprint: + +| ACME Role | Blueprint Role | Description | +|---|---|---| +| **ACME Client** | **User + EBW** | The RP representative using an European Business Wallet to authenticate and request certificates. The ACME Client software runs on or is invoked by the EBW. | +| **ACME Server** | **RA + CA** | The combined Registration Authority and Certificate Authority. The RA function handles identity verification, POA validation, and RP list checks. The CA function handles certificate generation and signing. These MAY be separate systems behind a single ACME endpoint. | +| — | **WP Leader** | Maintains the Lists of authorised RPs for the WE BUILD pilot. Functionally equivalent to the Registrar in the eIDAS ecosystem. | +| **Wallet Unit** | — | The EUDI/Business Wallet that verifies WRPACs during RP authentication. Not involved in issuance. | + +Detailed role descriptions: + +* **User (RP Representative):** A natural person authorized to act on behalf of the Wallet-Relying Party. Authenticates via an EBW and presents a Power of Attorney (POA) attestation as an EAA. +* **European Business Wallet (EBW):** The wallet application used by the RP representative to authenticate to the RA and to retrieve issued certificates. In the ACME flow, the EBW acts as the ACME Client or provides the authentication layer for the ACME Client. +* **Registration Authority (RA):** The TSP component that verifies the user's identity and POA, requests additional attributes for the RPRC, and checks the RP's presence in the authorised RP lists. Implemented as part of the ACME Server. +* **Certificate Authority (CA):** The TSP component that generates and signs WRPAC and WRPRC certificates. Implemented as the ACME Server's certificate issuance backend. +* **Lists of RP:** In the WE BUILD pilot, WP Leaders maintain lists of authorized Relying Parties. These lists serve as the functional equivalent of the national register of wallet-relying parties. The ACME Server checks these lists during authorization. + +# 5. Protocol Overview + +The WE BUILD WRPAC issuance protocol adapts the ACME framework (RFC 8555) to the EUDI Wallet trust model, implementing the issuance process defined in the WE BUILD Blueprint [5] and limited to Annex D1 of ETSI TS 119 475 [4] for MVP. + +The key adaptations are: + +* **Identifier type `wrp-id`**: replaces the `dns` identifier. The value is the WRP's unique identifier as it appears in the WE BUILD RP Lists. +* **Challenge type `registrar-api-01`**: replaces `http-01` / `dns-01`. The ACME Server verifies the WRP's presence in the WE BUILD RP Lists and confirms a token link between the ACME account and the RP entry. +* **EBW-based External Account Binding (EAB)**: REQUIRED for account creation. The RP representative authenticates via their EBW and presents a POA (EAA). The EAB binds the ACME account to the verified EBW identity and POA. +* **RA-integrated ACME Server**: the ACME Server combines RA and CA functions. The RA function validates identity, POA, and RP list membership. The CA function generates certificates. +* **Co-issuance of RPAC + RPRC**: the ACME order MAY result in both a WRPAC and a WRPRC being issued together, as specified in the Blueprint. +* **Certificate profile**: the issued certificate is an X.509 v3 WRPAC conforming to CIR 2025/848 Annex IV and ETSI TS 119 475 Annex D1. +* **Certificate Transparency**: the ACME Server logs all issued certificates per RFC 9162 [7]. + +All ACME messages are JSON payloads signed via JWS (RFC 7515 [6]), transported over HTTPS. + +> [!NOTE] +> CS-RPAC_02: The choice of ACME provides a proven, standardised, and automatable protocol for certificate lifecycle management. It implements the Blueprint's 11-step process (Section 7) while enabling interoperability testing with off-the-shelf ACME tooling. The RA and CA functions defined in the Blueprint are encapsulated within the ACME Server. + +# 5. ACME Profile for WRPAC Issuance + +## 5.1 ACME Resource Model + +This profile follows the standard ACME resource model: + +``` +directory + | + +--> newNonce + | + +-----+-----+-----+-----+--------+ + | | | | | | + V V V V V V +newAccount newOrder revokeCert keyChange + | | + V V + account order ---+--> finalize + | | + V +--> certificate + authorization +--> registrationCertificate + | + V + challenge +``` + +All standard ACME resource types and state transitions per RFC 8555 apply, with the extensions defined in Sections 5.2, 5.3, and 5.4. The `registrationCertificate` URL is a WE BUILD extension for WRPRC co-issuance. + +## 5.2 WRPAC Identifier Type + +**Type**: `wrp-id` + +**Value**: the WRP's unique identifier as it appears in the WE BUILD RP Lists. For interoperability testing, the identifier format is defined by the WP Leader. In the production eIDAS ecosystem, this would use CIR 2025/848 Annex I identifiers (EUID, LEI, etc.). + +**Example:** + +```json +{ + "type": "wrp-id", + "value": "NLKVK.12345678" +} +``` + +## 5.3 Challenge Type: registrar-api-01 + +**Type**: `registrar-api-01` + +**Mechanism:** + +1. The ACME Server generates a random `token` and returns it as part of the challenge object. +2. The ACME Client constructs a `key-authorization`: `token || '.' || base64url(Thumbprint(accountKey))`. +3. The ACME Client places the `key-authorization` as the `acme-challenge` attribute in its RP List entry. +4. The ACME Client signals readiness by POSTing to the challenge URL. +5. The ACME Server checks the RP List: + * WRP is present and authorized + * `acme-challenge` attribute matches the expected `key-authorization` + * Identity data is consistent with the account and POA +6. Authorization transitions to `valid` on success. + +**Challenge object:** + +```json +{ + "type": "registrar-api-01", + "url": "https://acme.example/chall/abc123", + "status": "pending", + "token": "DGyRejbN4F7wAJ3gPMR8Kw0VxBWgKEn2T4u-bQ_6Oc" +} +``` + +> [!NOTE] +> CS-RPAC_03: In the WE BUILD pilot, the RP Lists maintained by WP Leaders replace the national register. The `registrar-api-01` challenge verifies presence in these lists (Blueprint step 5). In production, this would query the Registrar's API per EC TS5 [9]. + +## 5.4 EBW-based Account Binding + +This profile extends ACME External Account Binding (RFC 8555 §7.3.4) with EBW authentication. This implements Blueprint steps 1-3. + +**Mechanism:** + +1. The RP representative authenticates to the RA using their EBW, presenting PID/EBWOID and a POA attestation (EAA). +2. The RA validates the POA and verifies the representative's authority. +3. The RA issues EAB credentials (Key ID + HMAC Key) bound to the verified identity and POA. +4. The ACME Client uses these EAB credentials during `newAccount`. + +**EAB in newAccount:** + +```json +{ + "termsOfServiceAgreed": true, + "contact": ["mailto:representative@rp.example"], + "externalAccountBinding": { + "protected": "", + "payload": "", + "signature": "" + } +} +``` + +> [!NOTE] +> CS-RPAC_04: For initial interoperability testing, EBW authentication MAY be simulated. EAB credentials are pre-provisioned by the RA/TSP after out-of-band identity verification. Future iterations will implement the full EBW-based OID4VP authentication flow for EAB provisioning. + +# 6. High-level Flows + +## 6.1 Directory Discovery + +1. The ACME Client fetches the directory document via HTTPS GET. +2. The directory includes metadata: `externalAccountRequired: true`, `supportedIdentifierTypes: ["wrp-id"]`, `supportedChallengeTypes: ["registrar-api-01"]`. + +## 6.2 Account Creation with EBW Authentication + +**Pre-ACME phase (Blueprint steps 1-3):** + +1. The RP representative connects to the TSP's RA portal/service. +2. The RA initiates an OID4VP request to the user's EBW, requesting PID/EBWOID and POA (EAA). +3. The EBW presents the requested credentials. +4. The RA validates the POA and verifies the representative's authority. +5. Optionally (Blueprint step 4): the RA requests additional attributes for RPRC production. +6. The RA issues EAB credentials to the user. + +**ACME phase:** + +7. The ACME Client generates an account key pair. +8. The ACME Client sends `newAccount` with the EAB binding. +9. The ACME Server validates the EAB and creates the account. + +## 6.3 Order Creation + +1. The ACME Client sends `newOrder` with one `wrp-id` identifier. +2. The ACME Server creates the order in `pending` state and returns authorization URL(s) and `finalize` URL. + +## 6.4 Authorization and Challenge Validation + +*Blueprint step 5: RP list check.* + +1. The ACME Client fetches the authorization. +2. The ACME Client completes the `registrar-api-01` challenge: compute `key-authorization`, place in RP List, signal readiness. +3. The ACME Server checks the RP List and validates. +4. Authorization transitions to `valid`. + +## 6.5 Order Finalization + +*Blueprint steps 6-9: order, issue, transmit, notify.* + +1. The ACME Client generates a certificate key pair and CSR. +2. The ACME Client sends the CSR to the `finalize` URL. +3. The ACME Server (RA) validates; the ACME Server (CA) generates WRPAC (and optionally WRPRC). +4. The CA submits to CT logs and obtains SCTs. +5. The order transitions to `valid` with a `certificate` URL (and optionally `registrationCertificate` URL). +6. The ACME Server MAY send out-of-band notification to the RP representative. + +## 6.6 Certificate Download + +*Blueprint steps 10-11: authenticate + retrieve.* + +1. The ACME Client sends POST-as-GET to the `certificate` URL (authenticated via account key bound to EBW). +2. The ACME Server returns the PEM certificate chain. +3. If WRPRC was co-issued, it is available at the `registrationCertificate` URL. + +## 6.7 Certificate Revocation + +1. Client-initiated: `revokeCert` request. +2. Server-initiated: upon RP removal from the RP List. + +# 7. Normative Requirements + +## 7.1 Common Requirements + +Both ACME Client and ACME Server **MUST**: + +1. Implement the ACME protocol per RFC 8555 [1] with the extensions in Section 6. +2. Use HTTPS for all communication. +3. Use JWS (RFC 7515 [6]) for all request payloads. +4. Implement nonce-based replay protection. + +## 7.2 ACME Server (CA / RA) + +The ACME Server **MUST**: + +1. Publish an ACME directory at a well-known URL. +2. Require EAB for all accounts (RFC 8555 §7.3.4). +3. Support `wrp-id` identifiers and `registrar-api-01` challenges. +4. Verify WRP presence in the WE BUILD RP Lists during challenge validation (Blueprint step 5). +5. Reject authorization if the WRP is not in the list or identity data is inconsistent. +6. Issue WRPACs exclusively to authorized WRPs. +7. Support the order state machine per RFC 8555 §7.1.6. +8. Support `revokeCert`. +9. Implement the RA function: POA validation (Blueprint steps 1-3) and RP List check (step 5). + +The ACME Server **SHOULD**: + +1. Support co-issuance of WRPRC alongside WRPAC in a single order. +2. Send out-of-band notification when certificates are ready (Blueprint step 9). +3. Support `keyChange`. + +## 7.3 ACME Client (WRP / EBW) + +The ACME Client **MUST**: + +1. Perform directory discovery. +2. Support EAB using credentials obtained through EBW authentication. +3. Support `wrp-id` identifiers and `registrar-api-01` challenges. +4. Generate key pairs and construct valid CSRs per Section 9.4. +5. Validate the returned certificate chain. +6. Support `revokeCert`. + +The ACME Client **SHOULD**: + +1. Integrate with the EBW for authentication during EAB provisioning. +2. Clean up `acme-challenge` from the RP List after successful issuance. + +## 7.4 Certificate Profile + +Issued WRPACs **MUST**: + +1. Be X.509 v3 certificates conforming to RFC 5280 [10]. +2. Contain the WRP's legal name in the Subject field (CIR 2025/848 Annex I §1). +3. Contain a user-friendly name where applicable (Annex I §2). +4. Contain at least one unique WRP identifier (Annex I §3). +5. Include a certificate policy OID (Annex IV §3). +6. Include certification path information (URI). +7. Include a SAN extension (DNS name or URI). +8. Include registration information per Annex I points 1, 2, and 8. +9. Comply with RFC 5755 [11] for attribute expression. +10. Conform to ETSI TS 119 475 Annex D1 [4]. +11. Use RSA (min. 3072 bits), ECDSA (P-256 or P-384), SHA-256 or stronger. + +> [!NOTE] +> CS-RPAC_06: For interoperability testing, implementations SHOULD support both RSA and ECDSA. NCP per ETSI EN 319 411-1 [12] is the baseline. + +## 7.5 Certificate Transparency + +The ACME Server **MUST** log all WRPACs in CT logs per RFC 9162 [7] and embed SCTs. + +> [!NOTE] +> CS-RPAC_07: For interoperability testing, test CT logs MAY be used. + +## 7.6 Revocation + +The ACME Server **MUST** support client-initiated (`revokeCert`) and server-initiated revocation (RP List removal), and publish status via OCSP and/or CRL. + +> [!NOTE] +> CS-RPAC_08: For interoperability testing, RP List removal MAY be simulated. + +## 7.7 Trusted List Integration + +1. The test environment MUST include a simulated Trusted List with the CA's trust anchor(s). +2. Wallet Units MUST verify WRPACs against this Trusted List. + +## 7.8 WE BUILD RP Lists + +Per the Blueprint assumptions [5]: + +1. Without actual Registrars, the present RAs of involved TSPs play the Registrar role. +2. WP Leaders establish and maintain lists of authorized RPs. +3. The ACME Server MUST check these lists as a precondition for issuance. +4. The RP List MUST support an `acme-challenge` metadata attribute. + +Each RP List entry MUST contain at minimum: + +* WRP unique identifier +* WRP legal name +* Authorization status (active / revoked) +* Metadata fields (including `acme-challenge`) + +# 8. Interface Definitions + +## 8.1 ACME Directory + +* **Method**: `GET` +* **URL**: `{base}/.well-known/acme-eudi-wrpac/directory` + +```json +{ + "newNonce": "https://acme.example/new-nonce", + "newAccount": "https://acme.example/new-acct", + "newOrder": "https://acme.example/new-order", + "revokeCert": "https://acme.example/revoke-cert", + "keyChange": "https://acme.example/key-change", + "meta": { + "termsOfService": "https://acme.example/terms", + "externalAccountRequired": true, + "supportedIdentifierTypes": ["wrp-id"], + "supportedChallengeTypes": ["registrar-api-01"], + "rprcCoIssuanceSupported": true + } +} +``` + +> [!NOTE] +> CS-RPAC_09: `rprcCoIssuanceSupported` is a WE BUILD extension indicating the server can co-issue RPAC and RPRC. + +## 8.2 Account Management with EBW/POA + +* **Method**: `POST` (JWS-signed) + +**Pre-condition**: EBW authentication + POA validation completed; EAB credentials received. + +```json +{ + "termsOfServiceAgreed": true, + "contact": ["mailto:representative@rp.example"], + "externalAccountBinding": { + "protected": "", + "payload": "", + "signature": "" + } +} +``` + +## 8.3 Order Lifecycle + +**Create order:** + +```json +{ + "identifiers": [ + { "type": "wrp-id", "value": "NLKVK.12345678" } + ] +} +``` + +**Order states**: `pending` → `ready` → `processing` → `valid` (or `invalid`) + +## 8.4 Authorization and Challenge + +**Authorization response:** + +```json +{ + "status": "pending", + "identifier": { "type": "wrp-id", "value": "NLKVK.12345678" }, + "challenges": [ + { + "type": "registrar-api-01", + "url": "https://acme.example/chall/chall789", + "status": "pending", + "token": "DGyRejbN4F7wAJ3gPMR8Kw0VxBWgKEn2T4u-bQ_6Oc" + } + ] +} +``` + +## 8.5 Finalize and Certificate + +**Finalize:** + +```json +{ "csr": "" } +``` + +**Order with co-issued RPRC (WE BUILD extension):** + +```json +{ + "status": "valid", + "certificate": "https://acme.example/cert/cert012", + "registrationCertificate": "https://acme.example/rprc/rprc012", + "finalize": "https://acme.example/order/ord456/finalize", + "authorizations": ["https://acme.example/authz/xyz789"] +} +``` + +**Certificate download**: `application/pem-certificate-chain` (end-entity first). + +## 8.6 Revocation + +```json +{ "certificate": "", "reason": 0 } +``` + +# 9. Conformance + +An implementation **conforms as an ACME Server (TSP: RA + CA)** if it: + +1. Implements ACME per RFC 8555 with the extensions in Section 6. +2. Publishes an ACME directory per Section 10.1. +3. Requires EAB with EBW/POA verification (Section 6.4). +4. Supports `wrp-id` identifiers and `registrar-api-01` challenges. +5. Verifies WRP presence in WE BUILD RP Lists (Section 9.8). +6. Issues certificates per Section 9.4. +7. Logs to CT logs (Section 9.5). +8. Supports revocation (Section 9.6). +9. Implements RA function: POA validation + RP List checks (Section 9.2). +10. Implements interfaces per Section 10. +11. Follows the Blueprint mapping per Section 7. + +An implementation **conforms as an ACME Client (WRP / EBW)** if it: + +1. Implements ACME per RFC 8555 with the extensions in Section 6. +2. Supports EBW-based EAB provisioning (Section 6.4). +3. Creates accounts with EAB, orders with `wrp-id`, and completes `registrar-api-01` challenges. +4. Submits valid CSRs and validates certificate chains. +5. Supports revocation. + +An implementation **conforms as a test environment** if it: + +1. Provides WE BUILD RP Lists with `acme-challenge` metadata support. +2. Provides a simulated Trusted List. +3. Provides a CT log (or test equivalent). +4. Supports EAB provisioning (or simulation). +5. Supports automated full-lifecycle testing. + +Profiles for specific WE BUILD credential types MUST NOT relax these requirements. + +Conformance of the Blueprint's 11-step issuance process [5] to the ACME protocol. + +| Blueprint Step | Description | ACME Operation | Section | +|---|---|---|---| +| **1** | User authenticates to RA using EBW | EBW authentication → EAB provisioning | 6.4, 8.2 | +| **2** | RA requests credentials | OID4VP request from RA to EBW | 6.4 | +| **3** | User supplies EAA (POA) | EBW presents POA to RA | 6.4 | +| **4** | RA requests additional RPRC attributes | Collected during EAB provisioning or as order metadata | 8.3 | +| **5** | RA checks RP in authorized RP lists | `registrar-api-01` challenge validation | 6.3, 8.4 | +| **6** | RA orders issuance of both certificates | ACME `finalize` (CSR submission) | 8.5 | +| **7** | CA issues RPAC and RPRC | ACME Server generates certificates, order → `valid` | 8.5 | +| **8** | CA transmits certificates to RA | Internal (ACME Server encapsulates RA+CA) | — | +| **9** | RA notifies user (e.g. by email) | Order polling: status → `valid`. Optional out-of-band notification. | 8.5 | +| **10** | User authenticates via EBW | Certificate download authenticated via JWS-signed POST-as-GET (account key bound to EBW via EAB) | 8.6 | +| **11** | User retrieves RPAC and RPRC | ACME certificate download from `certificate` URL | 8.6 | + +**Key design decisions:** + +* **Steps 1-3** (EBW auth + POA) are a **pre-ACME phase** producing EAB credentials. This cleanly separates human authentication from the machine protocol. +* **Step 4** (RPRC attributes) is collected either during EAB provisioning or as ACME order metadata. +* **Step 5** (RP list check) maps directly to the `registrar-api-01` challenge. +* **Steps 6-8** (order, issue, transmit) map to ACME `finalize` → `processing` → `valid`. +* **Steps 9-11** (notify, auth, retrieve) map to order polling and certificate download, with optional email notification. + +> [!NOTE] +> CS-RPAC_05: The Blueprint specifies RA and CA as separate actors. In this ACME profile they are encapsulated in a single endpoint. Implementations MAY separate them internally. This is consistent with real-world ACME CAs where the RA front-end and CA signing backend are separate systems behind one protocol interface. + +# References + +[1] IETF (2019) RFC 8555 — ACME. https://www.rfc-editor.org/rfc/rfc8555 + +[2] European Commission (2025) CIR (EU) 2025/848 on the registration of wallet-relying parties. https://eur-lex.europa.eu/eli/reg_impl/2025/848/oj + +[3] ETSI (2025) ETSI TS 119 411-8 v1.1.1 — Access Certificate Policy for EUDI Wallet Relying Parties. + +[4] ETSI (2025/2026) ETSI TS 119 475 v1.1.1 / v1.2.1 — Relying party attributes supporting EUDI Wallet user's authorisation decisions. In particular Annex D1. + +[5] WE BUILD (2026) WP4 Architecture Blueprint — RPAC/RPRC Documentation. https://webuild-consortium.github.io/wp4-architecture/blueprint/blueprint.html + +[6] WE BUILD (2025) ITB+ Reference Specification. https://github.com/webuild-consortium/wp4-interop-test-bed/ + +[7] IETF (2021) RFC 9162 — Certificate Transparency Version 2.0. https://www.rfc-editor.org/rfc/rfc9162 + +[6] IETF (2015) RFC 7515 — JWS. https://www.rfc-editor.org/rfc/rfc7515 + +[9] European Commission (2025/2026) EC TS5 — Common formats and API for RP registration information. + +[10] IETF (2008) RFC 5280 — X.509 PKI Certificate and CRL Profile. https://www.rfc-editor.org/rfc/rfc5280 + +[11] IETF (2010) RFC 5755 — Attribute Certificate Profile. https://www.rfc-editor.org/rfc/rfc5755 + +[12] ETSI (2023) ETSI EN 319 411-1 v1.4.1 — TSP Policy; Part 1: General requirements. + +[13] IETF (2003) RFC 3647 — Certificate Policy Framework. https://www.rfc-editor.org/rfc/rfc3647 + +[14] Regulation (EU) No 910/2014, amended by (EU) 2024/1183. + +[15] EUDI Wallet ARF v2.6. https://eu-digital-identity-wallet.github.io/eudi-doc-architecture-and-reference-framework/ \ No newline at end of file From cb1bcebfb2838a476dca8950595879b295ddc2bc Mon Sep 17 00:00:00 2001 From: jillesvano Date: Fri, 24 Apr 2026 16:00:55 +0200 Subject: [PATCH 02/14] First draft --- ...ty-access-and-registration-certificates.md | 499 +++++++++++------- 1 file changed, 308 insertions(+), 191 deletions(-) diff --git a/conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md b/conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md index a183bd7a..c7cb1923 100644 --- a/conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md +++ b/conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md @@ -1,46 +1,46 @@ # WE BUILD - Conformance Specification: Issuance of Relying Party Access and Registration Certificates -Version 0.8 / Draft -Date: 23 April 2026 +Version 0.1 / Draft +Date: 24 April 2026 ## Table of Contents - [1. Introduction](#1-introduction) - [2. Scope](#2-scope) - [3. Normative Language](#3-normative-language) -- [4. Roles and Components](#4-roles-and-components) -- [5. Protocol Overview](#5-protocol-overview) - - [5.1 ACME Profile for WRPAC Issuance](#51-acme-profile-for-wrpac-issuance) - - [5.2 ACME Resource Model](#52-acme-resource-model) - - [5.3 WRPAC Identifier Type](#53-wrpac-identifier-type) - - [5.4 Challenge Type: registrar-api-01](#54-challenge-type-registrar-api-01) - - [5.5 EBW-based Account Binding](#55-ebw-based-account-binding) - - [5.6 Mapping to WE BUILD Blueprint Issuance Process](#56-mapping-to-we-build-blueprint-issuance-process) -- [6. High-level Flows](#8-high-level-flows) - - [6.1 Directory Discovery](#81-directory-discovery) - - [6.2 Account Creation with EBW Authentication](#82-account-creation-with-ebw-authentication) - - [6.3 Order Creation](#83-order-creation) - - [6.4 Authorization and Challenge Validation](#84-authorization-and-challenge-validation) - - [6.5 Order Finalization](#85-order-finalization) - - [6.6 Certificate Download](#86-certificate-download) - - [6.7 Certificate Revocation](#87-certificate-revocation) -- [7. Normative Requirements](#9-normative-requirements) - - [7.1 Common Requirements](#91-common-requirements) - - [7.2 ACME Server (CA / RA)](#92-acme-server-ca--ra) - - [7.3 ACME Client (WRP / EBW)](#93-acme-client-wrp--ebw) - - [7.4 Certificate Profile](#94-certificate-profile) - - [7.5 Certificate Transparency](#95-certificate-transparency) - - [7.6 Revocation](#96-revocation) - - [7.7 Trusted List Integration](#97-trusted-list-integration) - - [7.8 WE BUILD RP Lists](#98-we-build-rp-lists) -- [8. Interface Definitions](#10-interface-definitions) - - [8.1 ACME Directory](#101-acme-directory) - - [8.2 Account Management with EBW/POA](#102-account-management-with-ebwpoa) - - [8.3 Order Lifecycle](#103-order-lifecycle) - - [8.4 Authorization and Challenge](#104-authorization-and-challenge) - - [8.5 Finalize and Certificate](#105-finalize-and-certificate) - - [8.6 Revocation](#106-revocation) -- [9. Conformance](#11-conformance) +- [4. Terminology](#4-terminology) +- [5. Roles and Components](#5-roles-and-components) +- [6. Protocol Overview](#6-protocol-overview) + - [6.1 ACME Resource Model](#61-acme-resource-model) + - [6.2 WRPAC Identifier Type](#62-wrpac-identifier-type) + - [6.3 Challenge Type: registrar-api-01](#63-challenge-type-registrar-api-01) + - [6.4 EBW-based Account Binding](#64-ebw-based-account-binding) +- [7. High-level Flows](#7-high-level-flows) + - [7.1 Directory Discovery](#71-directory-discovery) + - [7.2 Account Creation with EBW Authentication](#72-account-creation-with-ebw-authentication) + - [7.3 Order Creation](#73-order-creation) + - [7.4 Authorization and Challenge Validation](#74-authorization-and-challenge-validation) + - [7.5 Order Finalization](#75-order-finalization) + - [7.6 Certificate Download](#76-certificate-download) + - [7.7 Certificate Revocation](#77-certificate-revocation) +- [8. Normative Requirements](#8-normative-requirements) + - [8.1 Common Requirements](#81-common-requirements) + - [8.2 ACME Server (CA / RA)](#82-acme-server-ca--ra) + - [8.3 ACME Client (WRP / EBW)](#83-acme-client-wrp--ebw) + - [8.4 Certificate Profile](#84-certificate-profile) + - [8.5 Certificate Transparency](#85-certificate-transparency) + - [8.6 Revocation](#86-revocation) + - [8.7 Trusted List Integration](#87-trusted-list-integration) + - [8.8 WE BUILD RP Lists](#88-we-build-rp-lists) +- [9. Interface Definitions](#9-interface-definitions) + - [9.1 ACME Directory](#91-acme-directory) + - [9.2 Account Management with EBW/POA](#92-account-management-with-ebwpoa) + - [9.3 Order Lifecycle](#93-order-lifecycle) + - [9.4 Authorization and Challenge](#94-authorization-and-challenge) + - [9.5 Finalize and Certificate](#95-finalize-and-certificate) + - [9.6 Revocation](#96-revocation) +- [10. IANA Considerations](#10-iana-considerations) +- [11. Conformance](#11-conformance) - [References](#references) --- @@ -51,19 +51,22 @@ This document defines the **WE BUILD Consortium Conformance Specification (CS)** It profiles: -* Commission Implementing Regulation (EU) 2025/848 [2], in particular Article 7 and Annex IV -* ETSI TS 119 411-8 v1.1.1 [3] — Access Certificate Policy for EUDI Wallet Relying Parties -* ETSI TS 119 475 v1.2.1 [4] — Relying party attributes supporting EUDI Wallet user's authorisation decisions, in particular Annex D1 (Issuance process) +* Commission Implementing Regulation (EU) 2025/848 [2], in particular Article 7, Annex I, Annex IV, and Annex V (the latter for optional WRPRC co-issuance) +* ETSI TS 119 411-8 v1.1.1 (2025-10) [3] — Access Certificate Policy for EUDI Wallet Relying Parties +* ETSI TS 119 475 v1.2.1 (2026-03) [4] — Relying party attributes supporting EUDI Wallet user's authorisation decisions, in particular Annex D, Use Case 1 (Integrated model) * WE BUILD WP4 Architecture Blueprint [5] — RPAC/RPRC documentation and issuance process * IETF RFC 8555 [1] — Automatic Certificate Management Environment (ACME) -This specification positions the ACME protocol as the **technical implementation** of the issuance process defined in the WE BUILD Blueprint [5]. Section 7 provides an explicit mapping between the Blueprint's 11-step issuance flow and the ACME protocol operations. +This specification positions the ACME protocol as a **technical implementation** of the integrated issuance model described in ETSI TS 119 475 v1.2.1 Annex D, Use Case 1 [4], as adopted by the WE BUILD Blueprint [5]. Section 11 provides an explicit mapping between the WE BUILD Blueprint issuance workflow and the ACME protocol operations. This specification focuses **only on direct issuance** of WRPACs to registered Wallet-Relying Parties. The ACME flow is extended with EBW-based authentication and POA verification to align with the Blueprint's requirements for user authentication via European Business Wallets. This document is used to build the WE BUILD Interoperability Test Bed Plus (ITB+) [6]. > [!IMPORTANT] > CS-RPAC_01: This specification is intended for **interoperability testing only**, not for production deployment. Its purpose is to validate the technical feasibility of ACME-based WRPAC issuance and to establish interoperability between independent implementations. Production deployments will require additional security hardening, policy alignment, and conformity assessment beyond the scope of this document. +> [!NOTE] +> CS-RPAC_02: CIR (EU) 2025/848 [2] was adopted on 6 May 2025 and **applies from 24 December 2026**. Requirements in this specification that derive from CIR (EU) 2025/848 are therefore drafted in anticipation of that date of application. + # 2. Scope This specification defines: @@ -72,11 +75,13 @@ This specification defines: * A custom ACME identifier type (`wrp-id`) for WRP identifiers * A custom ACME challenge type (`registrar-api-01`) for verifying WRP authorization against WE BUILD RP Lists * An EBW-based account binding mechanism for user authentication via European Business Wallets +* Support for multi-instance issuance, enabling a single WRP to obtain separate WRPACs for multiple Relying Party Instances per the EUDI Wallet ARF v2.8 [15] * Requirements for: * ACME Servers (TSP Certificate Authorities and Registration Authorities) * ACME Clients (Wallet-Relying Parties using their EBW) * Protocol flows for: * Direct WRPAC issuance to an authorized WRP (with optional co-issuance of WRPRC) + * Multi-instance issuance (one WRPAC per Relying Party Instance) * Certificate revocation This specification does **not** cover: @@ -90,15 +95,30 @@ This specification does **not** cover: The keywords MUST, MUST NOT, REQUIRED, SHALL, SHOULD, SHOULD NOT, RECOMMENDED, MAY and OPTIONAL are to be interpreted as commonly used in technical specifications. -# 4. Roles and Components +# 4. Terminology + +The following terminology applies throughout this specification: + +* **WRPAC** — Wallet-Relying Party Access Certificate. This specification uses the term **WRPAC** (matching ETSI TS 119 475 v1.2.1 [4] and CIR (EU) 2025/848 [2]). The abbreviation **RPAC** as used in some WE BUILD Blueprint [5] and ARF [15] discussion documents is synonymous. +* **WRPRC** — Wallet-Relying Party Registration Certificate. The abbreviation **RPRC** used elsewhere is synonymous. +* **EBW** — European Business Wallet. +* **EUDIW** — European Digital Identity Wallet. +* **RP** — Relying Party; in this specification usually **WRP** (Wallet-Relying Party). +* **RA / CA / TSP** — Registration Authority, Certificate Authority, Trust Service Provider. +* **POA** — Power of Attorney. +* **EAA** — Electronic Attestation of Attributes. +* **PID** — Person Identification Data. +* **EBWOID** — European Business Wallet Organisational Identification Data. + +# 5. Roles and Components This specification uses the following roles, mapped to both the ACME protocol and the WE BUILD Blueprint: | ACME Role | Blueprint Role | Description | |---|---|---| -| **ACME Client** | **User + EBW** | The RP representative using an European Business Wallet to authenticate and request certificates. The ACME Client software runs on or is invoked by the EBW. | +| **ACME Client** | **User + EBW** | The RP representative using an European Business Wallet. The EBW plays two roles in this specification: (i) host or invocation environment for the ACME Client software, and (ii) user-authentication authority for EBW-based External Account Binding per RFC 8555 §7.3.4 [1]. | | **ACME Server** | **RA + CA** | The combined Registration Authority and Certificate Authority. The RA function handles identity verification, POA validation, and RP list checks. The CA function handles certificate generation and signing. These MAY be separate systems behind a single ACME endpoint. | -| — | **WP Leader** | Maintains the Lists of authorised RPs for the WE BUILD pilot. Functionally equivalent to the Registrar in the eIDAS ecosystem. | +| — | **Mock Registrar (RA/TSP)** | In the WE BUILD pilot, participating TSPs acting as mock Registrars maintain the Lists of authorised RPs. This role is functionally equivalent to the Member State Registrar in the production eIDAS ecosystem. In production, this function is performed by the national Registrar of the Member State concerned per the eIDAS framework. | | **Wallet Unit** | — | The EUDI/Business Wallet that verifies WRPACs during RP authentication. Not involved in issuance. | Detailed role descriptions: @@ -107,32 +127,31 @@ Detailed role descriptions: * **European Business Wallet (EBW):** The wallet application used by the RP representative to authenticate to the RA and to retrieve issued certificates. In the ACME flow, the EBW acts as the ACME Client or provides the authentication layer for the ACME Client. * **Registration Authority (RA):** The TSP component that verifies the user's identity and POA, requests additional attributes for the RPRC, and checks the RP's presence in the authorised RP lists. Implemented as part of the ACME Server. * **Certificate Authority (CA):** The TSP component that generates and signs WRPAC and WRPRC certificates. Implemented as the ACME Server's certificate issuance backend. -* **Lists of RP:** In the WE BUILD pilot, WP Leaders maintain lists of authorized Relying Parties. These lists serve as the functional equivalent of the national register of wallet-relying parties. The ACME Server checks these lists during authorization. +* **Mock Registrar:** In the WE BUILD pilot, participating TSPs acting as mock Registrars establish and maintain the Lists of authorized Relying Parties. These lists serve as the functional equivalent of the national register of wallet-relying parties per the Blueprint [5] MVP governance model. The ACME Server checks these lists during authorization. During ITB+ conformance testing, the WE BUILD RP List is served by the mock Registrar; in production, by the national Registrar of the Member State concerned. -# 5. Protocol Overview +# 6. Protocol Overview -The WE BUILD WRPAC issuance protocol adapts the ACME framework (RFC 8555) to the EUDI Wallet trust model, implementing the issuance process defined in the WE BUILD Blueprint [5] and limited to Annex D1 of ETSI TS 119 475 [4] for MVP. +The WE BUILD WRPAC issuance protocol adapts the ACME framework (RFC 8555) to the EUDI Wallet trust model, implementing the integrated issuance model described in ETSI TS 119 475 v1.2.1 Annex D, Use Case 1 [4] as adopted by the WE BUILD Blueprint [5]. The key adaptations are: -* **Identifier type `wrp-id`**: replaces the `dns` identifier. The value is the WRP's unique identifier as it appears in the WE BUILD RP Lists. -* **Challenge type `registrar-api-01`**: replaces `http-01` / `dns-01`. The ACME Server verifies the WRP's presence in the WE BUILD RP Lists and confirms a token link between the ACME account and the RP entry. +* **Identifier type `wrp-id`**: replaces the `dns` identifier. The value is the WRP's unique identifier as it appears in the WE BUILD RP Lists. For the `wrp-id` identifier type, the only defined challenge is `registrar-api-01`; the standard ACME challenges `http-01` and `dns-01` do not apply to `wrp-id`. +* **Challenge type `registrar-api-01`**: the ACME Server verifies the WRP's presence in the WE BUILD RP Lists and confirms a token link between the ACME account and the RP entry. The RP List entry supports multiple concurrent challenge tokens to enable multi-instance issuance (see §6.2). * **EBW-based External Account Binding (EAB)**: REQUIRED for account creation. The RP representative authenticates via their EBW and presents a POA (EAA). The EAB binds the ACME account to the verified EBW identity and POA. * **RA-integrated ACME Server**: the ACME Server combines RA and CA functions. The RA function validates identity, POA, and RP list membership. The CA function generates certificates. -* **Co-issuance of RPAC + RPRC**: the ACME order MAY result in both a WRPAC and a WRPRC being issued together, as specified in the Blueprint. -* **Certificate profile**: the issued certificate is an X.509 v3 WRPAC conforming to CIR 2025/848 Annex IV and ETSI TS 119 475 Annex D1. -* **Certificate Transparency**: the ACME Server logs all issued certificates per RFC 9162 [7]. +* **Multi-instance issuance**: a single WRP (`wrp-id`) MAY obtain multiple WRPACs, one per Relying Party Instance. Each instance is identified by an optional `instanceId` in the order (see §6.2). +* **Co-issuance of WRPAC + WRPRC**: the ACME order MAY result in both a WRPAC and a WRPRC being issued together, as specified in the Blueprint and in CIR (EU) 2025/848 Annex V [2]. +* **Certificate profile**: the issued certificate is an X.509 v3 WRPAC conforming to CIR 2025/848 Annex IV and ETSI TS 119 411-8 v1.1.1 [3], with attribute content as specified in ETSI TS 119 475 v1.2.1 clause 5 [4]. +* **Certificate Transparency**: the ACME Server logs all issued certificates per RFC 9162 [7] (see §8.5 for deployment guidance). -All ACME messages are JSON payloads signed via JWS (RFC 7515 [6]), transported over HTTPS. +All ACME messages are JSON payloads signed via JWS (RFC 7515 [8]), using JWK Thumbprints as specified in RFC 7638 [17], transported over HTTPS. > [!NOTE] -> CS-RPAC_02: The choice of ACME provides a proven, standardised, and automatable protocol for certificate lifecycle management. It implements the Blueprint's 11-step process (Section 7) while enabling interoperability testing with off-the-shelf ACME tooling. The RA and CA functions defined in the Blueprint are encapsulated within the ACME Server. - -# 5. ACME Profile for WRPAC Issuance +> CS-RPAC_03: The choice of ACME provides a proven, standardised, and automatable protocol for certificate lifecycle management. It implements the integrated issuance workflow described in ETSI TS 119 475 v1.2.1 Annex D, Use Case 1 (see Section 11) while enabling interoperability testing with off-the-shelf ACME tooling. The RA and CA functions defined in the Blueprint are encapsulated within the ACME Server. -## 5.1 ACME Resource Model +## 6.1 ACME Resource Model -This profile follows the standard ACME resource model: +This profile follows the standard ACME resource model defined in RFC 8555 §7.1 [1]: ``` directory @@ -145,22 +164,26 @@ directory newAccount newOrder revokeCert keyChange | | V V - account order ---+--> finalize - | | - V +--> certificate - authorization +--> registrationCertificate + account order ---+--> finalize (URL) + +--> certificate (URL) + +--> registrationCertificate (URL) [WE BUILD extension] + | + V + authorization | V challenge ``` -All standard ACME resource types and state transitions per RFC 8555 apply, with the extensions defined in Sections 5.2, 5.3, and 5.4. The `registrationCertificate` URL is a WE BUILD extension for WRPRC co-issuance. +All standard ACME resource types and state transitions per RFC 8555 apply, with the extensions defined in §6.2, §6.3, and §6.4. -## 5.2 WRPAC Identifier Type +The `registrationCertificate` URL is a WE BUILD extension to the standard RFC 8555 order object. Consistent with the conventions used by RFC 8555 for the `finalize` and `certificate` URLs, `registrationCertificate` is a URL-valued top-level field of the order object and is dereferenced via an authenticated POST-as-GET request. Registration considerations are described in §10 (IANA Considerations). + +## 6.2 WRPAC Identifier Type **Type**: `wrp-id` -**Value**: the WRP's unique identifier as it appears in the WE BUILD RP Lists. For interoperability testing, the identifier format is defined by the WP Leader. In the production eIDAS ecosystem, this would use CIR 2025/848 Annex I identifiers (EUID, LEI, etc.). +**Value**: the WRP's unique identifier as it appears in the WE BUILD RP Lists. For interoperability testing, the identifier format is defined by the mock Registrar. In the production eIDAS ecosystem, this would use CIR 2025/848 Annex I identifiers (EUID per CIR (EU) 2021/1042, LEI per CIR (EU) 2022/1860, EORI, national business register numbers, or VAT identification numbers). **Example:** @@ -171,19 +194,27 @@ All standard ACME resource types and state transitions per RFC 8555 apply, with } ``` -## 5.3 Challenge Type: registrar-api-01 +**Multi-instance issuance**: A single WRP identified by one `wrp-id` value MAY obtain multiple WRPACs, one per Relying Party Instance, in accordance with the Relying Party Instance model described in the EUDI Wallet ARF v2.8 [15]. Each order MAY include an optional `instanceId` string that uniquely identifies the specific Relying Party Instance within the WRP's deployment. When `instanceId` is provided: + +* The ACME Server MUST verify that the `instanceId` is distinct from all other currently valid WRPACs issued to the same `wrp-id`. +* The issued WRPAC MAY include the `instanceId` in a non-critical Subject Alternative Name extension or in the Subject's Common Name at the CA's discretion. +* The RP List entry for the `wrp-id` MUST support multiple concurrent `acme-challenge` tokens, keyed by `instanceId` (see §8.8). + +When `instanceId` is omitted, the ACME Server treats the order as targeting a single-instance deployment. If a valid WRPAC already exists for that `wrp-id` without an `instanceId`, the new order replaces the previous certificate; the ACME Server MUST initiate revocation of the superseded certificate upon successful issuance of the replacement (see §8.2 item 13). + +## 6.3 Challenge Type: registrar-api-01 **Type**: `registrar-api-01` **Mechanism:** 1. The ACME Server generates a random `token` and returns it as part of the challenge object. -2. The ACME Client constructs a `key-authorization`: `token || '.' || base64url(Thumbprint(accountKey))`. -3. The ACME Client places the `key-authorization` as the `acme-challenge` attribute in its RP List entry. +2. The ACME Client constructs a `key-authorization` per RFC 8555 §8.1 [1]: `token || '.' || base64url(Thumbprint(accountKey))`, where `Thumbprint` is the JWK Thumbprint defined in RFC 7638 [17]. +3. The ACME Client places the `key-authorization` as the `acme-challenge` attribute in its RP List entry. For multi-instance issuance, the value is placed under the key corresponding to the `instanceId` of the order (or `"default"` if no `instanceId` was specified). 4. The ACME Client signals readiness by POSTing to the challenge URL. 5. The ACME Server checks the RP List: * WRP is present and authorized - * `acme-challenge` attribute matches the expected `key-authorization` + * `acme-challenge` attribute (for the relevant `instanceId`) matches the expected `key-authorization` * Identity data is consistent with the account and POA 6. Authorization transitions to `valid` on success. @@ -199,11 +230,11 @@ All standard ACME resource types and state transitions per RFC 8555 apply, with ``` > [!NOTE] -> CS-RPAC_03: In the WE BUILD pilot, the RP Lists maintained by WP Leaders replace the national register. The `registrar-api-01` challenge verifies presence in these lists (Blueprint step 5). In production, this would query the Registrar's API per EC TS5 [9]. +> CS-RPAC_04: In the WE BUILD pilot, the RP Lists maintained by participating TSPs acting as mock Registrars replace the national register. The `registrar-api-01` challenge verifies presence in these lists (Blueprint step 5). In production, this would query the Registrar's API per EC TS5 [9] and EC TS6 [16]. -## 5.4 EBW-based Account Binding +## 6.4 EBW-based Account Binding -This profile extends ACME External Account Binding (RFC 8555 §7.3.4) with EBW authentication. This implements Blueprint steps 1-3. +This profile extends ACME External Account Binding (RFC 8555 §7.3.4 [1]) with EBW authentication. This implements Blueprint steps 1-3. **Mechanism:** @@ -214,37 +245,39 @@ This profile extends ACME External Account Binding (RFC 8555 §7.3.4) with EBW a **EAB in newAccount:** +Per RFC 8555 §7.3.4 [1], the `externalAccountBinding` value is a flattened JWS whose payload is the ACME account key in JWK form, and whose protected header carries `alg` (a MAC algorithm), `kid` (the CA-issued EAB Key Identifier), and `url`. + ```json { "termsOfServiceAgreed": true, "contact": ["mailto:representative@rp.example"], "externalAccountBinding": { - "protected": "", - "payload": "", + "protected": "", + "payload": "", "signature": "" } } ``` > [!NOTE] -> CS-RPAC_04: For initial interoperability testing, EBW authentication MAY be simulated. EAB credentials are pre-provisioned by the RA/TSP after out-of-band identity verification. Future iterations will implement the full EBW-based OID4VP authentication flow for EAB provisioning. +> CS-RPAC_05: **[MVP]** For initial interoperability testing, EBW authentication MAY be simulated. EAB credentials are pre-provisioned by the RA/TSP after out-of-band identity verification. **[MVP+]** Future iterations MUST implement the full EBW-based OID4VP authentication flow for EAB provisioning, including presentation of PID/EBWOID and POA (EAA) via OID4VP. Implementations declaring MVP+ conformance MUST support the full OID4VP-based EBW authentication flow. -# 6. High-level Flows +# 7. High-level Flows -## 6.1 Directory Discovery +## 7.1 Directory Discovery 1. The ACME Client fetches the directory document via HTTPS GET. 2. The directory includes metadata: `externalAccountRequired: true`, `supportedIdentifierTypes: ["wrp-id"]`, `supportedChallengeTypes: ["registrar-api-01"]`. -## 6.2 Account Creation with EBW Authentication +## 7.2 Account Creation with EBW Authentication **Pre-ACME phase (Blueprint steps 1-3):** 1. The RP representative connects to the TSP's RA portal/service. -2. The RA initiates an OID4VP request to the user's EBW, requesting PID/EBWOID and POA (EAA). +2. **[MVP+]** The RA initiates an OID4VP request to the user's EBW, requesting PID/EBWOID and POA (EAA). **[MVP]** This step MAY be performed out-of-band. 3. The EBW presents the requested credentials. 4. The RA validates the POA and verifies the representative's authority. -5. Optionally (Blueprint step 4): the RA requests additional attributes for RPRC production. +5. Optionally (Blueprint step 4): the RA requests additional attributes for WRPRC production. 6. The RA issues EAB credentials to the user. **ACME phase:** @@ -253,32 +286,33 @@ This profile extends ACME External Account Binding (RFC 8555 §7.3.4) with EBW a 8. The ACME Client sends `newAccount` with the EAB binding. 9. The ACME Server validates the EAB and creates the account. -## 6.3 Order Creation +## 7.3 Order Creation -1. The ACME Client sends `newOrder` with one `wrp-id` identifier. +1. The ACME Client sends `newOrder` with one `wrp-id` identifier and an optional `instanceId`. 2. The ACME Server creates the order in `pending` state and returns authorization URL(s) and `finalize` URL. +3. For multi-instance issuance: the ACME Server MUST verify that the `instanceId` (if provided) is not already in use by a currently valid WRPAC for the same `wrp-id`. -## 6.4 Authorization and Challenge Validation +## 7.4 Authorization and Challenge Validation *Blueprint step 5: RP list check.* 1. The ACME Client fetches the authorization. -2. The ACME Client completes the `registrar-api-01` challenge: compute `key-authorization`, place in RP List, signal readiness. +2. The ACME Client completes the `registrar-api-01` challenge: compute `key-authorization`, place in RP List (under the relevant `instanceId` key), signal readiness. 3. The ACME Server checks the RP List and validates. 4. Authorization transitions to `valid`. -## 6.5 Order Finalization +## 7.5 Order Finalization *Blueprint steps 6-9: order, issue, transmit, notify.* -1. The ACME Client generates a certificate key pair and CSR. +1. The ACME Client generates a certificate key pair and CSR. Per §8.3 item 4, the ACME Client MUST generate a distinct key pair for each Relying Party Instance. 2. The ACME Client sends the CSR to the `finalize` URL. 3. The ACME Server (RA) validates; the ACME Server (CA) generates WRPAC (and optionally WRPRC). 4. The CA submits to CT logs and obtains SCTs. 5. The order transitions to `valid` with a `certificate` URL (and optionally `registrationCertificate` URL). 6. The ACME Server MAY send out-of-band notification to the RP representative. -## 6.6 Certificate Download +## 7.6 Certificate Download *Blueprint steps 10-11: authenticate + retrieve.* @@ -286,118 +320,149 @@ This profile extends ACME External Account Binding (RFC 8555 §7.3.4) with EBW a 2. The ACME Server returns the PEM certificate chain. 3. If WRPRC was co-issued, it is available at the `registrationCertificate` URL. -## 6.7 Certificate Revocation +## 7.7 Certificate Revocation 1. Client-initiated: `revokeCert` request. -2. Server-initiated: upon RP removal from the RP List. +2. Server-initiated: upon RP removal from the RP List, or upon revocation of a specific Relying Party Instance. +3. For multi-instance deployments: revocation of one instance's WRPAC MUST NOT affect WRPACs issued to other instances of the same WRP. -# 7. Normative Requirements +# 8. Normative Requirements -## 7.1 Common Requirements +## 8.1 Common Requirements Both ACME Client and ACME Server **MUST**: -1. Implement the ACME protocol per RFC 8555 [1] with the extensions in Section 6. +1. Implement the ACME protocol per RFC 8555 [1] with the extensions in §6. 2. Use HTTPS for all communication. -3. Use JWS (RFC 7515 [6]) for all request payloads. +3. Use JWS (RFC 7515 [8]) for all request payloads, with JWK Thumbprints computed per RFC 7638 [17] where required by ACME. 4. Implement nonce-based replay protection. -## 7.2 ACME Server (CA / RA) +## 8.2 ACME Server (CA / RA) The ACME Server **MUST**: -1. Publish an ACME directory at a well-known URL. -2. Require EAB for all accounts (RFC 8555 §7.3.4). +1. Publish an ACME directory at a well-known URL (see §10 for IANA considerations). +2. Require EAB for all accounts (RFC 8555 §7.3.4 [1]). 3. Support `wrp-id` identifiers and `registrar-api-01` challenges. 4. Verify WRP presence in the WE BUILD RP Lists during challenge validation (Blueprint step 5). 5. Reject authorization if the WRP is not in the list or identity data is inconsistent. 6. Issue WRPACs exclusively to authorized WRPs. -7. Support the order state machine per RFC 8555 §7.1.6. +7. Support the order state transitions defined in RFC 8555 §7.1.6 "Status Changes" [1]. 8. Support `revokeCert`. 9. Implement the RA function: POA validation (Blueprint steps 1-3) and RP List check (step 5). +10. Support multi-instance issuance: accept an optional `instanceId` in orders, verify its uniqueness among active WRPACs for the same `wrp-id`, and issue separate WRPACs per instance (§6.2). +11. Enforce that each Relying Party Instance receives a distinct WRPAC bound to a distinct key pair (see §8.3 item 4). +12. Upon successful replacement of a single-instance WRPAC (new order without `instanceId` replacing an existing single-instance WRPAC), MUST initiate revocation of the superseded certificate (see §6.2). The ACME Server **SHOULD**: -1. Support co-issuance of WRPRC alongside WRPAC in a single order. +1. Support co-issuance of WRPRC alongside WRPAC in a single order (WRPRC profile per CIR (EU) 2025/848 Annex V [2]). 2. Send out-of-band notification when certificates are ready (Blueprint step 9). 3. Support `keyChange`. -## 7.3 ACME Client (WRP / EBW) +## 8.3 ACME Client (WRP / EBW) The ACME Client **MUST**: 1. Perform directory discovery. 2. Support EAB using credentials obtained through EBW authentication. 3. Support `wrp-id` identifiers and `registrar-api-01` challenges. -4. Generate key pairs and construct valid CSRs per Section 9.4. +4. Generate a distinct key pair per Relying Party Instance and construct valid CSRs per §8.4. For single-instance issuance, a single key pair applies; for multi-instance issuance, a distinct key pair MUST be used for each `instanceId`. This is the single normative key-separation rule applied by both the Client and the Server (see §8.2 item 11). 5. Validate the returned certificate chain. 6. Support `revokeCert`. The ACME Client **SHOULD**: -1. Integrate with the EBW for authentication during EAB provisioning. -2. Clean up `acme-challenge` from the RP List after successful issuance. +1. Integrate with the EBW for authentication during EAB provisioning (REQUIRED for MVP+ conformance). +2. Include an `instanceId` in orders for multi-instance deployments. +3. Clean up `acme-challenge` entries from the RP List after successful issuance or upon authorization failure. -## 7.4 Certificate Profile +## 8.4 Certificate Profile Issued WRPACs **MUST**: -1. Be X.509 v3 certificates conforming to RFC 5280 [10]. -2. Contain the WRP's legal name in the Subject field (CIR 2025/848 Annex I §1). -3. Contain a user-friendly name where applicable (Annex I §2). -4. Contain at least one unique WRP identifier (Annex I §3). -5. Include a certificate policy OID (Annex IV §3). -6. Include certification path information (URI). -7. Include a SAN extension (DNS name or URI). -8. Include registration information per Annex I points 1, 2, and 8. -9. Comply with RFC 5755 [11] for attribute expression. -10. Conform to ETSI TS 119 475 Annex D1 [4]. -11. Use RSA (min. 3072 bits), ECDSA (P-256 or P-384), SHA-256 or stronger. +1. Be X.509 v3 public-key certificates conforming to RFC 5280 [10]. +2. Comply with ETSI TS 119 411-8 v1.1.1 [3] (Access Certificate Policy for EUDI Wallet Relying Parties), which in turn applies the NCP policy as specified in ETSI EN 319 411-1 v1.5.1 [12] and the WRPAC-specific policy identifiers `NCP-n-eudiwrp` (natural person) and `NCP-l-eudiwrp` (legal person). +3. Contain the WRP's legal name in the Subject field (CIR 2025/848 Annex I point 1 [2]). +4. Contain a user-friendly name where applicable (Annex I point 2). +5. Contain at least one unique WRP identifier (Annex I point 3), selected from EUID, LEI, EORI, national business register number, or VAT identification number. +6. Include a certificate policy OID (Annex IV). +7. Include certification path information (URI). +8. Include a SAN extension (DNS name or URI). +9. Include registration information per Annex I points 1, 2, and 8. +10. Express attribute content (e.g. `organizationIdentifier`, semantic identifiers) using the Subject DN and certificate-extension attributes defined in ETSI EN 319 412-1 [18] and ETSI TS 119 475 v1.2.1 clause 5 [4]. +11. Conform to ETSI TS 119 475 v1.2.1 [4]. +12. Use RSA (min. 3072 bits), ECDSA (P-256 or P-384), SHA-256 or stronger. +13. Have a validity period of **one year** from the date of issuance. Shorter validity periods are NOT used in WE BUILD interoperability testing. + +> [!NOTE] +> CS-RPAC_06: For interoperability testing, implementations SHOULD support both RSA and ECDSA. NCP as defined in ETSI EN 319 411-1 v1.5.1 [12] is the baseline policy level, with the WRPAC-specific extensions of TS 119 411-8 v1.1.1 [3] applied on top. The one-year validity period applies uniformly in the WE BUILD ITB+ test environment. > [!NOTE] -> CS-RPAC_06: For interoperability testing, implementations SHOULD support both RSA and ECDSA. NCP per ETSI EN 319 411-1 [12] is the baseline. +> CS-RPAC_07: WRPACs are X.509 public-key certificates; they are not X.509 Attribute Certificates. Attribute content in WRPACs is expressed through public-key certificate extensions and ETSI-defined Subject DN attributes (ETSI EN 319 412 series [18], ETSI TS 119 475 v1.2.1 clause 5 [4]). The Attribute Certificate profile defined in RFC 5755 is therefore not applicable to WRPACs and is not used in this specification. -## 7.5 Certificate Transparency +## 8.5 Certificate Transparency -The ACME Server **MUST** log all WRPACs in CT logs per RFC 9162 [7] and embed SCTs. +The ACME Server **MUST** log all WRPACs in CT logs and embed SCTs. > [!NOTE] -> CS-RPAC_07: For interoperability testing, test CT logs MAY be used. +> CS-RPAC_08: RFC 9162 [7] ("Certificate Transparency Version 2.0", Experimental, December 2021) is the current IETF specification for CT and obsoletes RFC 6962. However, virtually all production CT log operators and browser CT policies currently implement RFC 6962. For interoperability testing in WE BUILD ITB+, implementations MAY use test CT logs and MAY rely on RFC 6962 where production tooling is not yet available for RFC 9162. Implementations MUST document which CT specification they implement. -## 7.6 Revocation +## 8.6 Revocation + +The ACME Server **MUST** support client-initiated (`revokeCert`) and server-initiated revocation (RP List removal or instance-specific revocation), and publish status via OCSP and/or CRL. -The ACME Server **MUST** support client-initiated (`revokeCert`) and server-initiated revocation (RP List removal), and publish status via OCSP and/or CRL. +For multi-instance deployments, revocation of one instance's WRPAC MUST be scoped to that instance and MUST NOT affect WRPACs issued to other instances of the same WRP. > [!NOTE] -> CS-RPAC_08: For interoperability testing, RP List removal MAY be simulated. +> CS-RPAC_09: For interoperability testing, RP List removal MAY be simulated. -## 7.7 Trusted List Integration +## 8.7 Trusted List Integration -1. The test environment MUST include a simulated Trusted List with the CA's trust anchor(s). -2. Wallet Units MUST verify WRPACs against this Trusted List. +1. The test environment MUST include a simulated Trusted List containing the trust anchor certificate(s) of the CA(s) participating in the WE BUILD pilot. Individual WRPAC end-entity certificates are NOT listed in the Trusted List; trust is established through the CA trust anchor chain. +2. Wallet Units MUST verify WRPACs by building and validating the certificate chain up to a CA trust anchor present in the Trusted List. -## 7.8 WE BUILD RP Lists +## 8.8 WE BUILD RP Lists -Per the Blueprint assumptions [5]: +In the WE BUILD pilot, participating TSPs acting as mock Registrars establish and maintain the Lists of authorized Relying Parties, in accordance with the Blueprint [5] MVP governance model. This is the functional equivalent of the national register of wallet-relying parties. -1. Without actual Registrars, the present RAs of involved TSPs play the Registrar role. -2. WP Leaders establish and maintain lists of authorized RPs. -3. The ACME Server MUST check these lists as a precondition for issuance. -4. The RP List MUST support an `acme-challenge` metadata attribute. +1. The ACME Server MUST check these lists as a precondition for issuance. +2. The RP List MUST support an `acme-challenge` metadata structure that accommodates multiple concurrent challenge tokens keyed by `instanceId`, to support multi-instance issuance. +3. In the absence of multi-instance ordering, the `acme-challenge` attribute MAY be a single string value (equivalent to `instanceId = "default"`). Each RP List entry MUST contain at minimum: * WRP unique identifier * WRP legal name * Authorization status (active / revoked) -* Metadata fields (including `acme-challenge`) +* Metadata fields including `acme-challenge`, structured as follows: + +```json +{ + "acme-challenge": { + "default": "", + "instance-A": "", + "instance-B": "" + } +} +``` + +For single-instance deployments, the following shorthand form is also valid: + +```json +{ + "acme-challenge": "" +} +``` + +The ACME Server MUST accept both forms and normalize them internally. -# 8. Interface Definitions +# 9. Interface Definitions -## 8.1 ACME Directory +## 9.1 ACME Directory * **Method**: `GET` -* **URL**: `{base}/.well-known/acme-eudi-wrpac/directory` +* **URL**: `{base}/acme-eudi-wrpac/directory` (see §10 for URI considerations) ```json { @@ -411,15 +476,16 @@ Each RP List entry MUST contain at minimum: "externalAccountRequired": true, "supportedIdentifierTypes": ["wrp-id"], "supportedChallengeTypes": ["registrar-api-01"], - "rprcCoIssuanceSupported": true + "rprcCoIssuanceSupported": true, + "multiInstanceIssuanceSupported": true } } ``` > [!NOTE] -> CS-RPAC_09: `rprcCoIssuanceSupported` is a WE BUILD extension indicating the server can co-issue RPAC and RPRC. +> CS-RPAC_10: `rprcCoIssuanceSupported` and `multiInstanceIssuanceSupported` are WE BUILD extensions to the ACME directory metadata. The former indicates the server can co-issue WRPAC and WRPRC; the latter indicates support for per-instance issuance per the ARF v2.8 Relying Party Instance model [15]. Registration considerations for these extensions are described in §10. -## 8.2 Account Management with EBW/POA +## 9.2 Account Management with EBW/POA * **Method**: `POST` (JWS-signed) @@ -430,16 +496,16 @@ Each RP List entry MUST contain at minimum: "termsOfServiceAgreed": true, "contact": ["mailto:representative@rp.example"], "externalAccountBinding": { - "protected": "", - "payload": "", + "protected": "", + "payload": "", "signature": "" } } ``` -## 8.3 Order Lifecycle +## 9.3 Order Lifecycle -**Create order:** +**Create order (single instance):** ```json { @@ -449,9 +515,22 @@ Each RP List entry MUST contain at minimum: } ``` -**Order states**: `pending` → `ready` → `processing` → `valid` (or `invalid`) +**Create order (multi-instance, with instanceId):** -## 8.4 Authorization and Challenge +```json +{ + "identifiers": [ + { "type": "wrp-id", "value": "NLKVK.12345678" } + ], + "instanceId": "production-eu-west-1" +} +``` + +The `instanceId` value is an opaque string chosen by the ACME Client. It MUST be unique among all currently valid WRPACs issued to the same `wrp-id`. Recommended format: a short alphanumeric label identifying the deployment context (e.g., environment, region, or service name). + +**Order states** (per RFC 8555 §7.1.6 "Status Changes" [1]): `pending` → `ready` → `processing` → `valid` (or `invalid`). + +## 9.4 Authorization and Challenge **Authorization response:** @@ -470,7 +549,9 @@ Each RP List entry MUST contain at minimum: } ``` -## 8.5 Finalize and Certificate +The ACME Client places the computed `key-authorization` in the RP List entry's `acme-challenge` structure under the key matching the `instanceId` of the order (or `"default"` if no `instanceId` was specified), before signalling readiness. + +## 9.5 Finalize and Certificate **Finalize:** @@ -478,11 +559,15 @@ Each RP List entry MUST contain at minimum: { "csr": "" } ``` -**Order with co-issued RPRC (WE BUILD extension):** +**Order with co-issued WRPRC (WE BUILD extension):** ```json { "status": "valid", + "identifiers": [ + { "type": "wrp-id", "value": "NLKVK.12345678" } + ], + "instanceId": "production-eu-west-1", "certificate": "https://acme.example/cert/cert012", "registrationCertificate": "https://acme.example/rprc/rprc012", "finalize": "https://acme.example/order/ord456/finalize", @@ -490,103 +575,135 @@ Each RP List entry MUST contain at minimum: } ``` -**Certificate download**: `application/pem-certificate-chain` (end-entity first). +The `registrationCertificate` field is a WE BUILD extension to the standard RFC 8555 order object. Like the standard `finalize` and `certificate` fields, it is a URL-valued top-level field of the order object and is dereferenced via an authenticated POST-as-GET. The `instanceId` field in the order response echoes the value from the order request. -## 8.6 Revocation +**Certificate download**: `application/pem-certificate-chain` per RFC 8555 §7.4.2 [1] (end-entity first). + +## 9.6 Revocation ```json { "certificate": "", "reason": 0 } ``` -# 9. Conformance +For multi-instance deployments, revocation targets the specific certificate identified by the DER-encoded value. Other WRPACs issued to the same `wrp-id` are unaffected. + +# 10. IANA Considerations + +This specification defines extensions to the ACME protocol that fall within IANA registries established by RFC 8555 and its successors, and introduces a custom URI suffix. For interoperability-testing purposes within WE BUILD ITB+, registration is not a prerequisite; for any future production use, the following registrations would be required. + +**ACME Identifier Types registry** (established by RFC 8555): the identifier type `wrp-id` (§6.2) would need to be registered. + +**Well-Known URI registry** (RFC 8615 [19]): the URI suffix `acme-eudi-wrpac` (§9.1) would need to be registered if the `/.well-known/` prefix is used. Alternatively, since RFC 8555 does not mandate the use of `/.well-known/` for the ACME directory, a conventional (non-`.well-known/`) path MAY be used, as shown in §9.1. Implementations intending to place the ACME directory under `/.well-known/acme-eudi-wrpac/` MUST complete the RFC 8615 registration process. + +**ACME directory metadata extensions**: the metadata fields `rprcCoIssuanceSupported` and `multiInstanceIssuanceSupported` (§9.1) are WE BUILD-specific extensions. The IANA "ACME Directory Metadata Fields" registry (established by RFC 8555) would need to be updated for any production deployment. + +**ACME order object extension**: the `registrationCertificate` URL field (§6.1, §9.5) is a WE BUILD-specific extension to the standard ACME order object. For production deployment, a URN-style field name (for example, `urn:webuild:acme:wrprc`) MAY be preferred to avoid conflicts with future IETF extensions. + +# 11. Conformance An implementation **conforms as an ACME Server (TSP: RA + CA)** if it: -1. Implements ACME per RFC 8555 with the extensions in Section 6. -2. Publishes an ACME directory per Section 10.1. -3. Requires EAB with EBW/POA verification (Section 6.4). +1. Implements ACME per RFC 8555 [1] with the extensions in §6. +2. Publishes an ACME directory per §9.1. +3. Requires EAB with EBW/POA verification (§6.4). 4. Supports `wrp-id` identifiers and `registrar-api-01` challenges. -5. Verifies WRP presence in WE BUILD RP Lists (Section 9.8). -6. Issues certificates per Section 9.4. -7. Logs to CT logs (Section 9.5). -8. Supports revocation (Section 9.6). -9. Implements RA function: POA validation + RP List checks (Section 9.2). -10. Implements interfaces per Section 10. -11. Follows the Blueprint mapping per Section 7. +5. Verifies WRP presence in WE BUILD RP Lists (§8.8). +6. Issues certificates per §8.4, including compliance with ETSI TS 119 411-8 v1.1.1 [3] and the attribute mapping in ETSI TS 119 475 v1.2.1 clause 5 [4]. +7. Logs to CT logs (§8.5). +8. Supports revocation (§8.6). +9. Implements RA function: POA validation + RP List checks (§8.2). +10. Supports multi-instance issuance per §6.2, §8.2, and §9.3. +11. Implements interfaces per §9. An implementation **conforms as an ACME Client (WRP / EBW)** if it: -1. Implements ACME per RFC 8555 with the extensions in Section 6. -2. Supports EBW-based EAB provisioning (Section 6.4). -3. Creates accounts with EAB, orders with `wrp-id`, and completes `registrar-api-01` challenges. -4. Submits valid CSRs and validates certificate chains. +1. Implements ACME per RFC 8555 [1] with the extensions in §6. +2. Supports EBW-based EAB provisioning (§6.4). +3. Creates accounts with EAB, orders with `wrp-id` (and optional `instanceId`), and completes `registrar-api-01` challenges. +4. Submits valid CSRs with distinct key pairs per instance, and validates certificate chains. 5. Supports revocation. An implementation **conforms as a test environment** if it: -1. Provides WE BUILD RP Lists with `acme-challenge` metadata support. -2. Provides a simulated Trusted List. +1. Provides WE BUILD RP Lists with `acme-challenge` metadata supporting both single-value and multi-instance (`instanceId`-keyed) formats. +2. Provides a simulated Trusted List containing CA trust anchor certificate(s). 3. Provides a CT log (or test equivalent). -4. Supports EAB provisioning (or simulation). -5. Supports automated full-lifecycle testing. +4. Supports EAB provisioning (or simulation per MVP scope). +5. Supports automated full-lifecycle testing, including multi-instance issuance scenarios. + +An **MVP+ conformant** implementation additionally: + +1. Implements the full OID4VP-based EBW authentication flow for EAB provisioning (§6.4, §7.2). +2. Accepts PID/EBWOID and POA (EAA) as presented via the EBW during the pre-ACME authentication phase. Profiles for specific WE BUILD credential types MUST NOT relax these requirements. -Conformance of the Blueprint's 11-step issuance process [5] to the ACME protocol. +**Mapping of the WE BUILD Blueprint issuance workflow (derived from ETSI TS 119 475 v1.2.1 Annex D, Use Case 1: Integrated model [4]) to ACME protocol operations:** | Blueprint Step | Description | ACME Operation | Section | |---|---|---|---| -| **1** | User authenticates to RA using EBW | EBW authentication → EAB provisioning | 6.4, 8.2 | -| **2** | RA requests credentials | OID4VP request from RA to EBW | 6.4 | -| **3** | User supplies EAA (POA) | EBW presents POA to RA | 6.4 | -| **4** | RA requests additional RPRC attributes | Collected during EAB provisioning or as order metadata | 8.3 | -| **5** | RA checks RP in authorized RP lists | `registrar-api-01` challenge validation | 6.3, 8.4 | -| **6** | RA orders issuance of both certificates | ACME `finalize` (CSR submission) | 8.5 | -| **7** | CA issues RPAC and RPRC | ACME Server generates certificates, order → `valid` | 8.5 | +| **1** | User authenticates to RA using EBW | EBW authentication → EAB provisioning | §6.4, §9.2 | +| **2** | RA requests credentials | OID4VP request from RA to EBW [MVP+] | §6.4 | +| **3** | User supplies EAA (POA) | EBW presents POA to RA | §6.4 | +| **4** | RA requests additional WRPRC attributes | Collected during EAB provisioning or as order metadata | §9.3 | +| **5** | RA checks RP in authorized RP lists | `registrar-api-01` challenge validation | §7.4, §9.4 | +| **6** | RA orders issuance of both certificates | ACME `finalize` (CSR submission) | §9.5 | +| **7** | CA issues WRPAC and WRPRC | ACME Server generates certificates, order → `valid` | §9.5 | | **8** | CA transmits certificates to RA | Internal (ACME Server encapsulates RA+CA) | — | -| **9** | RA notifies user (e.g. by email) | Order polling: status → `valid`. Optional out-of-band notification. | 8.5 | -| **10** | User authenticates via EBW | Certificate download authenticated via JWS-signed POST-as-GET (account key bound to EBW via EAB) | 8.6 | -| **11** | User retrieves RPAC and RPRC | ACME certificate download from `certificate` URL | 8.6 | +| **9** | RA notifies user (e.g. by email) | Order polling: status → `valid`. Optional out-of-band notification. | §9.5 | +| **10** | User authenticates via EBW | Certificate download authenticated via JWS-signed POST-as-GET (account key bound to EBW via EAB) | §9.6 | +| **11** | User retrieves WRPAC and WRPRC | ACME certificate download from `certificate` URL | §9.6 | + +> [!NOTE] +> CS-RPAC_11: The table above maps the eleven-step workflow used by the WE BUILD Blueprint [5] — which is derived from the integrated issuance model described in ETSI TS 119 475 v1.2.1 Annex D, Use Case 1 [4] — to the ACME protocol operations defined by this specification. ETSI TS 119 475 Annex D is informative and presents four use cases (D.1 Integrated model, D.2 Registrar-initiated, D.3 RP-initiated, D.4 Provider-assisted); the eleven discrete steps enumerated above are a WE BUILD refinement of the D.1 flow and are not themselves a normative ETSI enumeration. **Key design decisions:** -* **Steps 1-3** (EBW auth + POA) are a **pre-ACME phase** producing EAB credentials. This cleanly separates human authentication from the machine protocol. -* **Step 4** (RPRC attributes) is collected either during EAB provisioning or as ACME order metadata. +* **Steps 1-3** (EBW auth + POA) are a **pre-ACME phase** producing EAB credentials. This cleanly separates human authentication from the machine protocol. Full OID4VP-based EBW authentication is required for MVP+ conformance. +* **Step 4** (WRPRC attributes) is collected either during EAB provisioning or as ACME order metadata. * **Step 5** (RP list check) maps directly to the `registrar-api-01` challenge. * **Steps 6-8** (order, issue, transmit) map to ACME `finalize` → `processing` → `valid`. * **Steps 9-11** (notify, auth, retrieve) map to order polling and certificate download, with optional email notification. > [!NOTE] -> CS-RPAC_05: The Blueprint specifies RA and CA as separate actors. In this ACME profile they are encapsulated in a single endpoint. Implementations MAY separate them internally. This is consistent with real-world ACME CAs where the RA front-end and CA signing backend are separate systems behind one protocol interface. +> CS-RPAC_12: The Blueprint specifies RA and CA as separate actors. In this ACME profile they are encapsulated in a single endpoint. Implementations MAY separate them internally. This is consistent with real-world ACME CAs where the RA front-end and CA signing backend are separate systems behind one protocol interface. # References -[1] IETF (2019) RFC 8555 — ACME. https://www.rfc-editor.org/rfc/rfc8555 +[1] IETF (2019) RFC 8555 — Automatic Certificate Management Environment (ACME). Standards Track, March 2019. https://www.rfc-editor.org/rfc/rfc8555 -[2] European Commission (2025) CIR (EU) 2025/848 on the registration of wallet-relying parties. https://eur-lex.europa.eu/eli/reg_impl/2025/848/oj +[2] European Commission (2025) Commission Implementing Regulation (EU) 2025/848 of 6 May 2025 laying down rules for the application of Regulation (EU) No 910/2014 of the European Parliament and of the Council as regards the registration of wallet-relying parties. OJ L, 2025/848, 7.5.2025. Applies from 24 December 2026. http://data.europa.eu/eli/reg_impl/2025/848/oj -[3] ETSI (2025) ETSI TS 119 411-8 v1.1.1 — Access Certificate Policy for EUDI Wallet Relying Parties. +[3] ETSI (2025) ETSI TS 119 411-8 v1.1.1 (2025-10) — Electronic Signatures and Trust Infrastructures (ESI); Policy and security requirements for Trust Service Providers issuing certificates; Part 8: Access Certificate Policy for EUDI Wallet Relying Parties. -[4] ETSI (2025/2026) ETSI TS 119 475 v1.1.1 / v1.2.1 — Relying party attributes supporting EUDI Wallet user's authorisation decisions. In particular Annex D1. +[4] ETSI (2026) ETSI TS 119 475 v1.2.1 (2026-03) — Electronic Signatures and Trust Infrastructures (ESI); Relying party attributes supporting EUDI Wallet user's authorisation decisions. In particular Annex D, Use Case 1 (Integrated model). [5] WE BUILD (2026) WP4 Architecture Blueprint — RPAC/RPRC Documentation. https://webuild-consortium.github.io/wp4-architecture/blueprint/blueprint.html [6] WE BUILD (2025) ITB+ Reference Specification. https://github.com/webuild-consortium/wp4-interop-test-bed/ -[7] IETF (2021) RFC 9162 — Certificate Transparency Version 2.0. https://www.rfc-editor.org/rfc/rfc9162 +[7] IETF (2021) RFC 9162 — Certificate Transparency Version 2.0. Experimental, December 2021. Obsoletes RFC 6962. https://www.rfc-editor.org/rfc/rfc9162 + +[8] IETF (2015) RFC 7515 — JSON Web Signature (JWS). Standards Track, May 2015. https://www.rfc-editor.org/rfc/rfc7515 + +[9] European Commission (2025/2026) EC TS5 — Common formats and API for RP registration information. Draft, eu-digital-identity-wallet/eudi-doc-standards-and-technical-specifications repository. + +[10] IETF (2008) RFC 5280 — Internet X.509 Public Key Infrastructure Certificate and Certificate Revocation List (CRL) Profile. Standards Track, May 2008. https://www.rfc-editor.org/rfc/rfc5280 + +[11] [Reserved] -[6] IETF (2015) RFC 7515 — JWS. https://www.rfc-editor.org/rfc/rfc7515 +[12] ETSI (2025) ETSI EN 319 411-1 v1.5.1 (2025-04) — Electronic Signatures and Trust Infrastructures (ESI); Policy and security requirements for Trust Service Providers issuing certificates; Part 1: General requirements. -[9] European Commission (2025/2026) EC TS5 — Common formats and API for RP registration information. +[13] IETF (2003) RFC 3647 — Internet X.509 Public Key Infrastructure Certificate Policy and Certification Practices Framework. Informational, November 2003. https://www.rfc-editor.org/rfc/rfc3647 -[10] IETF (2008) RFC 5280 — X.509 PKI Certificate and CRL Profile. https://www.rfc-editor.org/rfc/rfc5280 +[14] Regulation (EU) No 910/2014, as amended by Regulation (EU) 2024/1183 of the European Parliament and of the Council of 11 April 2024 establishing the European Digital Identity Framework. -[11] IETF (2010) RFC 5755 — Attribute Certificate Profile. https://www.rfc-editor.org/rfc/rfc5755 +[15] EUDI Wallet Architecture and Reference Framework v2.8.0. https://eu-digital-identity-wallet.github.io/eudi-doc-architecture-and-reference-framework/ -[12] ETSI (2023) ETSI EN 319 411-1 v1.4.1 — TSP Policy; Part 1: General requirements. +[16] European Commission (2025/2026) EC TS6 — Common set of RP registration information. Draft, eu-digital-identity-wallet/eudi-doc-standards-and-technical-specifications repository. -[13] IETF (2003) RFC 3647 — Certificate Policy Framework. https://www.rfc-editor.org/rfc/rfc3647 +[17] IETF (2015) RFC 7638 — JSON Web Key (JWK) Thumbprint. Standards Track, September 2015. https://www.rfc-editor.org/rfc/rfc7638 -[14] Regulation (EU) No 910/2014, amended by (EU) 2024/1183. +[18] ETSI EN 319 412-1 — Electronic Signatures and Trust Infrastructures (ESI); Certificate Profiles; Part 1: Overview and common data structures. -[15] EUDI Wallet ARF v2.6. https://eu-digital-identity-wallet.github.io/eudi-doc-architecture-and-reference-framework/ \ No newline at end of file +[19] IETF (2019) RFC 8615 — Well-Known Uniform Resource Identifiers (URIs). Standards Track, May 2019. https://www.rfc-editor.org/rfc/rfc8615 \ No newline at end of file From b542a9c3d52d03899ba912281faa2ea338edc642 Mon Sep 17 00:00:00 2001 From: jillesvano Date: Fri, 24 Apr 2026 17:32:23 +0200 Subject: [PATCH 03/14] Final proposal for RP certificates subgroup --- ...ty-access-and-registration-certificates.md | 239 +++++++++--------- 1 file changed, 118 insertions(+), 121 deletions(-) diff --git a/conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md b/conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md index c7cb1923..e99c2f48 100644 --- a/conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md +++ b/conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md @@ -1,46 +1,45 @@ # WE BUILD - Conformance Specification: Issuance of Relying Party Access and Registration Certificates -Version 0.1 / Draft +Version 0.2 / First proposal Date: 24 April 2026 ## Table of Contents - [1. Introduction](#1-introduction) - [2. Scope](#2-scope) -- [3. Normative Language](#3-normative-language) -- [4. Terminology](#4-terminology) -- [5. Roles and Components](#5-roles-and-components) -- [6. Protocol Overview](#6-protocol-overview) - - [6.1 ACME Resource Model](#61-acme-resource-model) - - [6.2 WRPAC Identifier Type](#62-wrpac-identifier-type) - - [6.3 Challenge Type: registrar-api-01](#63-challenge-type-registrar-api-01) - - [6.4 EBW-based Account Binding](#64-ebw-based-account-binding) -- [7. High-level Flows](#7-high-level-flows) - - [7.1 Directory Discovery](#71-directory-discovery) - - [7.2 Account Creation with EBW Authentication](#72-account-creation-with-ebw-authentication) - - [7.3 Order Creation](#73-order-creation) - - [7.4 Authorization and Challenge Validation](#74-authorization-and-challenge-validation) - - [7.5 Order Finalization](#75-order-finalization) - - [7.6 Certificate Download](#76-certificate-download) - - [7.7 Certificate Revocation](#77-certificate-revocation) -- [8. Normative Requirements](#8-normative-requirements) - - [8.1 Common Requirements](#81-common-requirements) - - [8.2 ACME Server (CA / RA)](#82-acme-server-ca--ra) - - [8.3 ACME Client (WRP / EBW)](#83-acme-client-wrp--ebw) - - [8.4 Certificate Profile](#84-certificate-profile) - - [8.5 Certificate Transparency](#85-certificate-transparency) +- [3. Normative Language and Terminology](#3-normative-language-and-terminology) +- [4. Roles and Components](#4-roles-and-components) +- [5. Protocol Overview](#5-protocol-overview) + - [5.1 ACME Resource Model](#51-acme-resource-model) + - [5.2 WRPAC Identifier Type](#52-wrpac-identifier-type) + - [5.3 Challenge Type: registrar-api-01](#53-challenge-type-registrar-api-01) + - [5.4 EBW-based Account Binding](#54-ebw-based-account-binding) +- [6. High-level Flows](#6-high-level-flows) + - [6.1 Directory Discovery](#61-directory-discovery) + - [6.2 Account Creation with EBW Authentication](#62-account-creation-with-ebw-authentication) + - [6.3 Order Creation](#63-order-creation) + - [6.4 Authorization and Challenge Validation](#64-authorization-and-challenge-validation) + - [6.5 Order Finalization](#65-order-finalization) + - [6.6 Certificate Download](#66-certificate-download) + - [6.7 Certificate Revocation](#67-certificate-revocation) +- [7. Normative Requirements](#7-normative-requirements) + - [7.1 Common Requirements](#71-common-requirements) + - [7.2 ACME Server (CA / RA)](#72-acme-server-ca--ra) + - [7.3 ACME Client (WRP / EBW)](#73-acme-client-wrp--ebw) + - [7.4 Certificate Profile](#74-certificate-profile) + - [7.5 Certificate Transparency](#75-certificate-transparency) + - [7.6 Revocation](#76-revocation) + - [7.7 Trusted List Integration](#77-trusted-list-integration) + - [7.8 WE BUILD RP Lists](#78-we-build-rp-lists) + - [7.9 IANA Considerations](#79-iana-considerations) +- [8. Interface Definitions](#8-interface-definitions) + - [8.1 ACME Directory](#81-acme-directory) + - [8.2 Account Management with EBW/POA](#82-account-management-with-ebwpoa) + - [8.3 Order Lifecycle](#83-order-lifecycle) + - [8.4 Authorization and Challenge](#84-authorization-and-challenge) + - [8.5 Finalize and Certificate](#85-finalize-and-certificate) - [8.6 Revocation](#86-revocation) - - [8.7 Trusted List Integration](#87-trusted-list-integration) - - [8.8 WE BUILD RP Lists](#88-we-build-rp-lists) -- [9. Interface Definitions](#9-interface-definitions) - - [9.1 ACME Directory](#91-acme-directory) - - [9.2 Account Management with EBW/POA](#92-account-management-with-ebwpoa) - - [9.3 Order Lifecycle](#93-order-lifecycle) - - [9.4 Authorization and Challenge](#94-authorization-and-challenge) - - [9.5 Finalize and Certificate](#95-finalize-and-certificate) - - [9.6 Revocation](#96-revocation) -- [10. IANA Considerations](#10-iana-considerations) -- [11. Conformance](#11-conformance) +- [9. Conformance](#9-conformance) - [References](#references) --- @@ -57,7 +56,7 @@ It profiles: * WE BUILD WP4 Architecture Blueprint [5] — RPAC/RPRC documentation and issuance process * IETF RFC 8555 [1] — Automatic Certificate Management Environment (ACME) -This specification positions the ACME protocol as a **technical implementation** of the integrated issuance model described in ETSI TS 119 475 v1.2.1 Annex D, Use Case 1 [4], as adopted by the WE BUILD Blueprint [5]. Section 11 provides an explicit mapping between the WE BUILD Blueprint issuance workflow and the ACME protocol operations. +This specification positions the ACME protocol as a **technical implementation** of the integrated issuance model described in ETSI TS 119 475 v1.2.1 Annex D, Use Case 1 [4], as adopted by the WE BUILD Blueprint [5]. Section 9 provides an explicit mapping between the WE BUILD Blueprint issuance workflow and the ACME protocol operations. This specification focuses **only on direct issuance** of WRPACs to registered Wallet-Relying Parties. The ACME flow is extended with EBW-based authentication and POA verification to align with the Blueprint's requirements for user authentication via European Business Wallets. This document is used to build the WE BUILD Interoperability Test Bed Plus (ITB+) [6]. @@ -91,12 +90,10 @@ This specification does **not** cover: * Production deployment requirements (conformity assessment, CAB audits, national policy extensions) * Proximity use cases for certificate presentation -# 3. Normative Language +# 3. Normative Language and Terminology The keywords MUST, MUST NOT, REQUIRED, SHALL, SHOULD, SHOULD NOT, RECOMMENDED, MAY and OPTIONAL are to be interpreted as commonly used in technical specifications. -# 4. Terminology - The following terminology applies throughout this specification: * **WRPAC** — Wallet-Relying Party Access Certificate. This specification uses the term **WRPAC** (matching ETSI TS 119 475 v1.2.1 [4] and CIR (EU) 2025/848 [2]). The abbreviation **RPAC** as used in some WE BUILD Blueprint [5] and ARF [15] discussion documents is synonymous. @@ -110,7 +107,7 @@ The following terminology applies throughout this specification: * **PID** — Person Identification Data. * **EBWOID** — European Business Wallet Organisational Identification Data. -# 5. Roles and Components +# 4. Roles and Components This specification uses the following roles, mapped to both the ACME protocol and the WE BUILD Blueprint: @@ -129,27 +126,27 @@ Detailed role descriptions: * **Certificate Authority (CA):** The TSP component that generates and signs WRPAC and WRPRC certificates. Implemented as the ACME Server's certificate issuance backend. * **Mock Registrar:** In the WE BUILD pilot, participating TSPs acting as mock Registrars establish and maintain the Lists of authorized Relying Parties. These lists serve as the functional equivalent of the national register of wallet-relying parties per the Blueprint [5] MVP governance model. The ACME Server checks these lists during authorization. During ITB+ conformance testing, the WE BUILD RP List is served by the mock Registrar; in production, by the national Registrar of the Member State concerned. -# 6. Protocol Overview +# 5. Protocol Overview The WE BUILD WRPAC issuance protocol adapts the ACME framework (RFC 8555) to the EUDI Wallet trust model, implementing the integrated issuance model described in ETSI TS 119 475 v1.2.1 Annex D, Use Case 1 [4] as adopted by the WE BUILD Blueprint [5]. The key adaptations are: * **Identifier type `wrp-id`**: replaces the `dns` identifier. The value is the WRP's unique identifier as it appears in the WE BUILD RP Lists. For the `wrp-id` identifier type, the only defined challenge is `registrar-api-01`; the standard ACME challenges `http-01` and `dns-01` do not apply to `wrp-id`. -* **Challenge type `registrar-api-01`**: the ACME Server verifies the WRP's presence in the WE BUILD RP Lists and confirms a token link between the ACME account and the RP entry. The RP List entry supports multiple concurrent challenge tokens to enable multi-instance issuance (see §6.2). +* **Challenge type `registrar-api-01`**: the ACME Server verifies the WRP's presence in the WE BUILD RP Lists and confirms a token link between the ACME account and the RP entry. The RP List entry supports multiple concurrent challenge tokens to enable multi-instance issuance (see §5.2). * **EBW-based External Account Binding (EAB)**: REQUIRED for account creation. The RP representative authenticates via their EBW and presents a POA (EAA). The EAB binds the ACME account to the verified EBW identity and POA. * **RA-integrated ACME Server**: the ACME Server combines RA and CA functions. The RA function validates identity, POA, and RP list membership. The CA function generates certificates. -* **Multi-instance issuance**: a single WRP (`wrp-id`) MAY obtain multiple WRPACs, one per Relying Party Instance. Each instance is identified by an optional `instanceId` in the order (see §6.2). +* **Multi-instance issuance**: a single WRP (`wrp-id`) MAY obtain multiple WRPACs, one per Relying Party Instance. Each instance is identified by an optional `instanceId` in the order (see §5.2). * **Co-issuance of WRPAC + WRPRC**: the ACME order MAY result in both a WRPAC and a WRPRC being issued together, as specified in the Blueprint and in CIR (EU) 2025/848 Annex V [2]. * **Certificate profile**: the issued certificate is an X.509 v3 WRPAC conforming to CIR 2025/848 Annex IV and ETSI TS 119 411-8 v1.1.1 [3], with attribute content as specified in ETSI TS 119 475 v1.2.1 clause 5 [4]. -* **Certificate Transparency**: the ACME Server logs all issued certificates per RFC 9162 [7] (see §8.5 for deployment guidance). +* **Certificate Transparency**: the ACME Server logs all issued certificates per RFC 9162 [7] (see §7.5 for deployment guidance). All ACME messages are JSON payloads signed via JWS (RFC 7515 [8]), using JWK Thumbprints as specified in RFC 7638 [17], transported over HTTPS. > [!NOTE] -> CS-RPAC_03: The choice of ACME provides a proven, standardised, and automatable protocol for certificate lifecycle management. It implements the integrated issuance workflow described in ETSI TS 119 475 v1.2.1 Annex D, Use Case 1 (see Section 11) while enabling interoperability testing with off-the-shelf ACME tooling. The RA and CA functions defined in the Blueprint are encapsulated within the ACME Server. +> CS-RPAC_03: The choice of ACME provides a proven, standardised, and automatable protocol for certificate lifecycle management. It implements the integrated issuance workflow described in ETSI TS 119 475 v1.2.1 Annex D, Use Case 1 (see Section 9) while enabling interoperability testing with off-the-shelf ACME tooling. The RA and CA functions defined in the Blueprint are encapsulated within the ACME Server. -## 6.1 ACME Resource Model +## 5.1 ACME Resource Model This profile follows the standard ACME resource model defined in RFC 8555 §7.1 [1]: @@ -175,11 +172,11 @@ newAccount newOrder revokeCert keyChange challenge ``` -All standard ACME resource types and state transitions per RFC 8555 apply, with the extensions defined in §6.2, §6.3, and §6.4. +All standard ACME resource types and state transitions per RFC 8555 apply, with the extensions defined in §5.2, §5.3, and §5.4. -The `registrationCertificate` URL is a WE BUILD extension to the standard RFC 8555 order object. Consistent with the conventions used by RFC 8555 for the `finalize` and `certificate` URLs, `registrationCertificate` is a URL-valued top-level field of the order object and is dereferenced via an authenticated POST-as-GET request. Registration considerations are described in §10 (IANA Considerations). +The `registrationCertificate` URL is a WE BUILD extension to the standard RFC 8555 order object. Consistent with the conventions used by RFC 8555 for the `finalize` and `certificate` URLs, `registrationCertificate` is a URL-valued top-level field of the order object and is dereferenced via an authenticated POST-as-GET request. Registration considerations are described in §7.9 (IANA Considerations). -## 6.2 WRPAC Identifier Type +## 5.2 WRPAC Identifier Type **Type**: `wrp-id` @@ -198,11 +195,11 @@ The `registrationCertificate` URL is a WE BUILD extension to the standard RFC 85 * The ACME Server MUST verify that the `instanceId` is distinct from all other currently valid WRPACs issued to the same `wrp-id`. * The issued WRPAC MAY include the `instanceId` in a non-critical Subject Alternative Name extension or in the Subject's Common Name at the CA's discretion. -* The RP List entry for the `wrp-id` MUST support multiple concurrent `acme-challenge` tokens, keyed by `instanceId` (see §8.8). +* The RP List entry for the `wrp-id` MUST support multiple concurrent `acme-challenge` tokens, keyed by `instanceId` (see §7.8). -When `instanceId` is omitted, the ACME Server treats the order as targeting a single-instance deployment. If a valid WRPAC already exists for that `wrp-id` without an `instanceId`, the new order replaces the previous certificate; the ACME Server MUST initiate revocation of the superseded certificate upon successful issuance of the replacement (see §8.2 item 13). +When `instanceId` is omitted, the ACME Server treats the order as targeting a single-instance deployment. If a valid WRPAC already exists for that `wrp-id` without an `instanceId`, the new order replaces the previous certificate; the ACME Server MUST initiate revocation of the superseded certificate upon successful issuance of the replacement (see §7.2 item 12). -## 6.3 Challenge Type: registrar-api-01 +## 5.3 Challenge Type: registrar-api-01 **Type**: `registrar-api-01` @@ -232,7 +229,7 @@ When `instanceId` is omitted, the ACME Server treats the order as targeting a si > [!NOTE] > CS-RPAC_04: In the WE BUILD pilot, the RP Lists maintained by participating TSPs acting as mock Registrars replace the national register. The `registrar-api-01` challenge verifies presence in these lists (Blueprint step 5). In production, this would query the Registrar's API per EC TS5 [9] and EC TS6 [16]. -## 6.4 EBW-based Account Binding +## 5.4 EBW-based Account Binding This profile extends ACME External Account Binding (RFC 8555 §7.3.4 [1]) with EBW authentication. This implements Blueprint steps 1-3. @@ -262,14 +259,14 @@ Per RFC 8555 §7.3.4 [1], the `externalAccountBinding` value is a flattened JWS > [!NOTE] > CS-RPAC_05: **[MVP]** For initial interoperability testing, EBW authentication MAY be simulated. EAB credentials are pre-provisioned by the RA/TSP after out-of-band identity verification. **[MVP+]** Future iterations MUST implement the full EBW-based OID4VP authentication flow for EAB provisioning, including presentation of PID/EBWOID and POA (EAA) via OID4VP. Implementations declaring MVP+ conformance MUST support the full OID4VP-based EBW authentication flow. -# 7. High-level Flows +# 6. High-level Flows -## 7.1 Directory Discovery +## 6.1 Directory Discovery 1. The ACME Client fetches the directory document via HTTPS GET. 2. The directory includes metadata: `externalAccountRequired: true`, `supportedIdentifierTypes: ["wrp-id"]`, `supportedChallengeTypes: ["registrar-api-01"]`. -## 7.2 Account Creation with EBW Authentication +## 6.2 Account Creation with EBW Authentication **Pre-ACME phase (Blueprint steps 1-3):** @@ -286,13 +283,13 @@ Per RFC 8555 §7.3.4 [1], the `externalAccountBinding` value is a flattened JWS 8. The ACME Client sends `newAccount` with the EAB binding. 9. The ACME Server validates the EAB and creates the account. -## 7.3 Order Creation +## 6.3 Order Creation 1. The ACME Client sends `newOrder` with one `wrp-id` identifier and an optional `instanceId`. 2. The ACME Server creates the order in `pending` state and returns authorization URL(s) and `finalize` URL. 3. For multi-instance issuance: the ACME Server MUST verify that the `instanceId` (if provided) is not already in use by a currently valid WRPAC for the same `wrp-id`. -## 7.4 Authorization and Challenge Validation +## 6.4 Authorization and Challenge Validation *Blueprint step 5: RP list check.* @@ -301,18 +298,18 @@ Per RFC 8555 §7.3.4 [1], the `externalAccountBinding` value is a flattened JWS 3. The ACME Server checks the RP List and validates. 4. Authorization transitions to `valid`. -## 7.5 Order Finalization +## 6.5 Order Finalization *Blueprint steps 6-9: order, issue, transmit, notify.* -1. The ACME Client generates a certificate key pair and CSR. Per §8.3 item 4, the ACME Client MUST generate a distinct key pair for each Relying Party Instance. +1. The ACME Client generates a certificate key pair and CSR. Per §7.3 item 4, the ACME Client MUST generate a distinct key pair for each Relying Party Instance. 2. The ACME Client sends the CSR to the `finalize` URL. 3. The ACME Server (RA) validates; the ACME Server (CA) generates WRPAC (and optionally WRPRC). 4. The CA submits to CT logs and obtains SCTs. 5. The order transitions to `valid` with a `certificate` URL (and optionally `registrationCertificate` URL). 6. The ACME Server MAY send out-of-band notification to the RP representative. -## 7.6 Certificate Download +## 6.6 Certificate Download *Blueprint steps 10-11: authenticate + retrieve.* @@ -320,28 +317,28 @@ Per RFC 8555 §7.3.4 [1], the `externalAccountBinding` value is a flattened JWS 2. The ACME Server returns the PEM certificate chain. 3. If WRPRC was co-issued, it is available at the `registrationCertificate` URL. -## 7.7 Certificate Revocation +## 6.7 Certificate Revocation 1. Client-initiated: `revokeCert` request. 2. Server-initiated: upon RP removal from the RP List, or upon revocation of a specific Relying Party Instance. 3. For multi-instance deployments: revocation of one instance's WRPAC MUST NOT affect WRPACs issued to other instances of the same WRP. -# 8. Normative Requirements +# 7. Normative Requirements -## 8.1 Common Requirements +## 7.1 Common Requirements Both ACME Client and ACME Server **MUST**: -1. Implement the ACME protocol per RFC 8555 [1] with the extensions in §6. +1. Implement the ACME protocol per RFC 8555 [1] with the extensions in §5. 2. Use HTTPS for all communication. 3. Use JWS (RFC 7515 [8]) for all request payloads, with JWK Thumbprints computed per RFC 7638 [17] where required by ACME. 4. Implement nonce-based replay protection. -## 8.2 ACME Server (CA / RA) +## 7.2 ACME Server (CA / RA) The ACME Server **MUST**: -1. Publish an ACME directory at a well-known URL (see §10 for IANA considerations). +1. Publish an ACME directory at a well-known URL (see §7.9 for IANA considerations). 2. Require EAB for all accounts (RFC 8555 §7.3.4 [1]). 3. Support `wrp-id` identifiers and `registrar-api-01` challenges. 4. Verify WRP presence in the WE BUILD RP Lists during challenge validation (Blueprint step 5). @@ -350,9 +347,9 @@ The ACME Server **MUST**: 7. Support the order state transitions defined in RFC 8555 §7.1.6 "Status Changes" [1]. 8. Support `revokeCert`. 9. Implement the RA function: POA validation (Blueprint steps 1-3) and RP List check (step 5). -10. Support multi-instance issuance: accept an optional `instanceId` in orders, verify its uniqueness among active WRPACs for the same `wrp-id`, and issue separate WRPACs per instance (§6.2). -11. Enforce that each Relying Party Instance receives a distinct WRPAC bound to a distinct key pair (see §8.3 item 4). -12. Upon successful replacement of a single-instance WRPAC (new order without `instanceId` replacing an existing single-instance WRPAC), MUST initiate revocation of the superseded certificate (see §6.2). +10. Support multi-instance issuance: accept an optional `instanceId` in orders, verify its uniqueness among active WRPACs for the same `wrp-id`, and issue separate WRPACs per instance (§5.2). +11. Enforce that each Relying Party Instance receives a distinct WRPAC bound to a distinct key pair (see §7.3 item 4). +12. Upon successful replacement of a single-instance WRPAC (new order without `instanceId` replacing an existing single-instance WRPAC), MUST initiate revocation of the superseded certificate (see §5.2). The ACME Server **SHOULD**: @@ -360,14 +357,14 @@ The ACME Server **SHOULD**: 2. Send out-of-band notification when certificates are ready (Blueprint step 9). 3. Support `keyChange`. -## 8.3 ACME Client (WRP / EBW) +## 7.3 ACME Client (WRP / EBW) The ACME Client **MUST**: 1. Perform directory discovery. 2. Support EAB using credentials obtained through EBW authentication. 3. Support `wrp-id` identifiers and `registrar-api-01` challenges. -4. Generate a distinct key pair per Relying Party Instance and construct valid CSRs per §8.4. For single-instance issuance, a single key pair applies; for multi-instance issuance, a distinct key pair MUST be used for each `instanceId`. This is the single normative key-separation rule applied by both the Client and the Server (see §8.2 item 11). +4. Generate a distinct key pair per Relying Party Instance and construct valid CSRs per §7.4. For single-instance issuance, a single key pair applies; for multi-instance issuance, a distinct key pair MUST be used for each `instanceId`. This is the single normative key-separation rule applied by both the Client and the Server (see §7.2 item 11). 5. Validate the returned certificate chain. 6. Support `revokeCert`. @@ -377,7 +374,7 @@ The ACME Client **SHOULD**: 2. Include an `instanceId` in orders for multi-instance deployments. 3. Clean up `acme-challenge` entries from the RP List after successful issuance or upon authorization failure. -## 8.4 Certificate Profile +## 7.4 Certificate Profile Issued WRPACs **MUST**: @@ -401,14 +398,14 @@ Issued WRPACs **MUST**: > [!NOTE] > CS-RPAC_07: WRPACs are X.509 public-key certificates; they are not X.509 Attribute Certificates. Attribute content in WRPACs is expressed through public-key certificate extensions and ETSI-defined Subject DN attributes (ETSI EN 319 412 series [18], ETSI TS 119 475 v1.2.1 clause 5 [4]). The Attribute Certificate profile defined in RFC 5755 is therefore not applicable to WRPACs and is not used in this specification. -## 8.5 Certificate Transparency +## 7.5 Certificate Transparency The ACME Server **MUST** log all WRPACs in CT logs and embed SCTs. > [!NOTE] > CS-RPAC_08: RFC 9162 [7] ("Certificate Transparency Version 2.0", Experimental, December 2021) is the current IETF specification for CT and obsoletes RFC 6962. However, virtually all production CT log operators and browser CT policies currently implement RFC 6962. For interoperability testing in WE BUILD ITB+, implementations MAY use test CT logs and MAY rely on RFC 6962 where production tooling is not yet available for RFC 9162. Implementations MUST document which CT specification they implement. -## 8.6 Revocation +## 7.6 Revocation The ACME Server **MUST** support client-initiated (`revokeCert`) and server-initiated revocation (RP List removal or instance-specific revocation), and publish status via OCSP and/or CRL. @@ -417,12 +414,12 @@ For multi-instance deployments, revocation of one instance's WRPAC MUST be scope > [!NOTE] > CS-RPAC_09: For interoperability testing, RP List removal MAY be simulated. -## 8.7 Trusted List Integration +## 7.7 Trusted List Integration 1. The test environment MUST include a simulated Trusted List containing the trust anchor certificate(s) of the CA(s) participating in the WE BUILD pilot. Individual WRPAC end-entity certificates are NOT listed in the Trusted List; trust is established through the CA trust anchor chain. 2. Wallet Units MUST verify WRPACs by building and validating the certificate chain up to a CA trust anchor present in the Trusted List. -## 8.8 WE BUILD RP Lists +## 7.8 WE BUILD RP Lists In the WE BUILD pilot, participating TSPs acting as mock Registrars establish and maintain the Lists of authorized Relying Parties, in accordance with the Blueprint [5] MVP governance model. This is the functional equivalent of the national register of wallet-relying parties. @@ -457,12 +454,24 @@ For single-instance deployments, the following shorthand form is also valid: The ACME Server MUST accept both forms and normalize them internally. -# 9. Interface Definitions +## 7.9 IANA Considerations + +This specification defines extensions to the ACME protocol that fall within IANA registries established by RFC 8555 and its successors, and introduces a custom URI suffix. For interoperability-testing purposes within WE BUILD ITB+, registration is not a prerequisite; for any future production use, the following registrations would be required. + +**ACME Identifier Types registry** (established by RFC 8555): the identifier type `wrp-id` (§5.2) would need to be registered. + +**Well-Known URI registry** (RFC 8615 [19]): the URI suffix `acme-eudi-wrpac` (§8.1) would need to be registered if the `/.well-known/` prefix is used. Alternatively, since RFC 8555 does not mandate the use of `/.well-known/` for the ACME directory, a conventional (non-`.well-known/`) path MAY be used, as shown in §8.1. Implementations intending to place the ACME directory under `/.well-known/acme-eudi-wrpac/` MUST complete the RFC 8615 registration process. + +**ACME directory metadata extensions**: the metadata fields `rprcCoIssuanceSupported` and `multiInstanceIssuanceSupported` (§8.1) are WE BUILD-specific extensions. The IANA "ACME Directory Metadata Fields" registry (established by RFC 8555) would need to be updated for any production deployment. + +**ACME order object extension**: the `registrationCertificate` URL field (§5.1, §8.5) is a WE BUILD-specific extension to the standard ACME order object. For production deployment, a URN-style field name (for example, `urn:webuild:acme:wrprc`) MAY be preferred to avoid conflicts with future IETF extensions. + +# 8. Interface Definitions -## 9.1 ACME Directory +## 8.1 ACME Directory * **Method**: `GET` -* **URL**: `{base}/acme-eudi-wrpac/directory` (see §10 for URI considerations) +* **URL**: `{base}/acme-eudi-wrpac/directory` (see §7.9 for URI considerations) ```json { @@ -483,9 +492,9 @@ The ACME Server MUST accept both forms and normalize them internally. ``` > [!NOTE] -> CS-RPAC_10: `rprcCoIssuanceSupported` and `multiInstanceIssuanceSupported` are WE BUILD extensions to the ACME directory metadata. The former indicates the server can co-issue WRPAC and WRPRC; the latter indicates support for per-instance issuance per the ARF v2.8 Relying Party Instance model [15]. Registration considerations for these extensions are described in §10. +> CS-RPAC_10: `rprcCoIssuanceSupported` and `multiInstanceIssuanceSupported` are WE BUILD extensions to the ACME directory metadata. The former indicates the server can co-issue WRPAC and WRPRC; the latter indicates support for per-instance issuance per the ARF v2.8 Relying Party Instance model [15]. Registration considerations for these extensions are described in §7.9. -## 9.2 Account Management with EBW/POA +## 8.2 Account Management with EBW/POA * **Method**: `POST` (JWS-signed) @@ -503,7 +512,7 @@ The ACME Server MUST accept both forms and normalize them internally. } ``` -## 9.3 Order Lifecycle +## 8.3 Order Lifecycle **Create order (single instance):** @@ -530,7 +539,7 @@ The `instanceId` value is an opaque string chosen by the ACME Client. It MUST be **Order states** (per RFC 8555 §7.1.6 "Status Changes" [1]): `pending` → `ready` → `processing` → `valid` (or `invalid`). -## 9.4 Authorization and Challenge +## 8.4 Authorization and Challenge **Authorization response:** @@ -551,7 +560,7 @@ The `instanceId` value is an opaque string chosen by the ACME Client. It MUST be The ACME Client places the computed `key-authorization` in the RP List entry's `acme-challenge` structure under the key matching the `instanceId` of the order (or `"default"` if no `instanceId` was specified), before signalling readiness. -## 9.5 Finalize and Certificate +## 8.5 Finalize and Certificate **Finalize:** @@ -579,7 +588,7 @@ The `registrationCertificate` field is a WE BUILD extension to the standard RFC **Certificate download**: `application/pem-certificate-chain` per RFC 8555 §7.4.2 [1] (end-entity first). -## 9.6 Revocation +## 8.6 Revocation ```json { "certificate": "", "reason": 0 } @@ -587,38 +596,26 @@ The `registrationCertificate` field is a WE BUILD extension to the standard RFC For multi-instance deployments, revocation targets the specific certificate identified by the DER-encoded value. Other WRPACs issued to the same `wrp-id` are unaffected. -# 10. IANA Considerations - -This specification defines extensions to the ACME protocol that fall within IANA registries established by RFC 8555 and its successors, and introduces a custom URI suffix. For interoperability-testing purposes within WE BUILD ITB+, registration is not a prerequisite; for any future production use, the following registrations would be required. - -**ACME Identifier Types registry** (established by RFC 8555): the identifier type `wrp-id` (§6.2) would need to be registered. - -**Well-Known URI registry** (RFC 8615 [19]): the URI suffix `acme-eudi-wrpac` (§9.1) would need to be registered if the `/.well-known/` prefix is used. Alternatively, since RFC 8555 does not mandate the use of `/.well-known/` for the ACME directory, a conventional (non-`.well-known/`) path MAY be used, as shown in §9.1. Implementations intending to place the ACME directory under `/.well-known/acme-eudi-wrpac/` MUST complete the RFC 8615 registration process. - -**ACME directory metadata extensions**: the metadata fields `rprcCoIssuanceSupported` and `multiInstanceIssuanceSupported` (§9.1) are WE BUILD-specific extensions. The IANA "ACME Directory Metadata Fields" registry (established by RFC 8555) would need to be updated for any production deployment. - -**ACME order object extension**: the `registrationCertificate` URL field (§6.1, §9.5) is a WE BUILD-specific extension to the standard ACME order object. For production deployment, a URN-style field name (for example, `urn:webuild:acme:wrprc`) MAY be preferred to avoid conflicts with future IETF extensions. - -# 11. Conformance +# 9. Conformance An implementation **conforms as an ACME Server (TSP: RA + CA)** if it: -1. Implements ACME per RFC 8555 [1] with the extensions in §6. -2. Publishes an ACME directory per §9.1. -3. Requires EAB with EBW/POA verification (§6.4). +1. Implements ACME per RFC 8555 [1] with the extensions in §5. +2. Publishes an ACME directory per §8.1. +3. Requires EAB with EBW/POA verification (§5.4). 4. Supports `wrp-id` identifiers and `registrar-api-01` challenges. -5. Verifies WRP presence in WE BUILD RP Lists (§8.8). -6. Issues certificates per §8.4, including compliance with ETSI TS 119 411-8 v1.1.1 [3] and the attribute mapping in ETSI TS 119 475 v1.2.1 clause 5 [4]. -7. Logs to CT logs (§8.5). -8. Supports revocation (§8.6). -9. Implements RA function: POA validation + RP List checks (§8.2). -10. Supports multi-instance issuance per §6.2, §8.2, and §9.3. -11. Implements interfaces per §9. +5. Verifies WRP presence in WE BUILD RP Lists (§7.8). +6. Issues certificates per §7.4, including compliance with ETSI TS 119 411-8 v1.1.1 [3] and the attribute mapping in ETSI TS 119 475 v1.2.1 clause 5 [4]. +7. Logs to CT logs (§7.5). +8. Supports revocation (§7.6). +9. Implements RA function: POA validation + RP List checks (§7.2). +10. Supports multi-instance issuance per §5.2, §7.2, and §8.3. +11. Implements interfaces per §8. An implementation **conforms as an ACME Client (WRP / EBW)** if it: -1. Implements ACME per RFC 8555 [1] with the extensions in §6. -2. Supports EBW-based EAB provisioning (§6.4). +1. Implements ACME per RFC 8555 [1] with the extensions in §5. +2. Supports EBW-based EAB provisioning (§5.4). 3. Creates accounts with EAB, orders with `wrp-id` (and optional `instanceId`), and completes `registrar-api-01` challenges. 4. Submits valid CSRs with distinct key pairs per instance, and validates certificate chains. 5. Supports revocation. @@ -633,7 +630,7 @@ An implementation **conforms as a test environment** if it: An **MVP+ conformant** implementation additionally: -1. Implements the full OID4VP-based EBW authentication flow for EAB provisioning (§6.4, §7.2). +1. Implements the full OID4VP-based EBW authentication flow for EAB provisioning (§5.4, §6.2). 2. Accepts PID/EBWOID and POA (EAA) as presented via the EBW during the pre-ACME authentication phase. Profiles for specific WE BUILD credential types MUST NOT relax these requirements. @@ -642,17 +639,17 @@ Profiles for specific WE BUILD credential types MUST NOT relax these requirement | Blueprint Step | Description | ACME Operation | Section | |---|---|---|---| -| **1** | User authenticates to RA using EBW | EBW authentication → EAB provisioning | §6.4, §9.2 | -| **2** | RA requests credentials | OID4VP request from RA to EBW [MVP+] | §6.4 | -| **3** | User supplies EAA (POA) | EBW presents POA to RA | §6.4 | -| **4** | RA requests additional WRPRC attributes | Collected during EAB provisioning or as order metadata | §9.3 | -| **5** | RA checks RP in authorized RP lists | `registrar-api-01` challenge validation | §7.4, §9.4 | -| **6** | RA orders issuance of both certificates | ACME `finalize` (CSR submission) | §9.5 | -| **7** | CA issues WRPAC and WRPRC | ACME Server generates certificates, order → `valid` | §9.5 | +| **1** | User authenticates to RA using EBW | EBW authentication → EAB provisioning | §5.4, §8.2 | +| **2** | RA requests credentials | OID4VP request from RA to EBW [MVP+] | §5.4 | +| **3** | User supplies EAA (POA) | EBW presents POA to RA | §5.4 | +| **4** | RA requests additional WRPRC attributes | Collected during EAB provisioning or as order metadata | §8.3 | +| **5** | RA checks RP in authorized RP lists | `registrar-api-01` challenge validation | §6.4, §8.4 | +| **6** | RA orders issuance of both certificates | ACME `finalize` (CSR submission) | §8.5 | +| **7** | CA issues WRPAC and WRPRC | ACME Server generates certificates, order → `valid` | §8.5 | | **8** | CA transmits certificates to RA | Internal (ACME Server encapsulates RA+CA) | — | -| **9** | RA notifies user (e.g. by email) | Order polling: status → `valid`. Optional out-of-band notification. | §9.5 | -| **10** | User authenticates via EBW | Certificate download authenticated via JWS-signed POST-as-GET (account key bound to EBW via EAB) | §9.6 | -| **11** | User retrieves WRPAC and WRPRC | ACME certificate download from `certificate` URL | §9.6 | +| **9** | RA notifies user (e.g. by email) | Order polling: status → `valid`. Optional out-of-band notification. | §8.5 | +| **10** | User authenticates via EBW | Certificate download authenticated via JWS-signed POST-as-GET (account key bound to EBW via EAB) | §8.6 | +| **11** | User retrieves WRPAC and WRPRC | ACME certificate download from `certificate` URL | §8.6 | > [!NOTE] > CS-RPAC_11: The table above maps the eleven-step workflow used by the WE BUILD Blueprint [5] — which is derived from the integrated issuance model described in ETSI TS 119 475 v1.2.1 Annex D, Use Case 1 [4] — to the ACME protocol operations defined by this specification. ETSI TS 119 475 Annex D is informative and presents four use cases (D.1 Integrated model, D.2 Registrar-initiated, D.3 RP-initiated, D.4 Provider-assisted); the eleven discrete steps enumerated above are a WE BUILD refinement of the D.1 flow and are not themselves a normative ETSI enumeration. From 71fc31d1545688eae6ea5e97c5b1835640bd2294 Mon Sep 17 00:00:00 2001 From: jmisset-cb Date: Tue, 19 May 2026 16:57:20 +0200 Subject: [PATCH 04/14] deployment playbooks and tests added --- CLAUDE.md | 50 ++ deployment/.gitignore | 5 + deployment/README.md | 150 +++++ deployment/ansible.cfg | 11 + deployment/files/wrpac-acme/pyproject.toml | 21 + .../wrpac-acme/wrpac_acme.egg-info/PKG-INFO | 11 + .../wrpac_acme.egg-info/SOURCES.txt | 13 + .../wrpac_acme.egg-info/dependency_links.txt | 1 + .../wrpac_acme.egg-info/requires.txt | 6 + .../wrpac_acme.egg-info/top_level.txt | 1 + .../files/wrpac-acme/wrpac_acme/__init__.py | 1 + .../files/wrpac-acme/wrpac_acme/client.py | 243 ++++++++ .../files/wrpac-acme/wrpac_acme/crypto.py | 92 ++++ .../files/wrpac-acme/wrpac_acme/dogtag.py | 91 +++ .../files/wrpac-acme/wrpac_acme/facade.py | 519 ++++++++++++++++++ .../wrpac-acme/wrpac_acme/negative_tests.py | 232 ++++++++ .../files/wrpac-acme/wrpac_acme/rp_list.py | 129 +++++ .../files/wrpac-acme/wrpac_acme/storage.py | 79 +++ deployment/files/wrpacCert.cfg.j2 | 141 +++++ deployment/group_vars/all.yml | 53 ++ deployment/inventory.yml | 6 + deployment/nginx/ca-public.conf | 81 +++ deployment/playbooks/00-prereqs.yml | 74 +++ deployment/playbooks/10-dogtag-ca.yml | 108 ++++ deployment/playbooks/20-dogtag-acme.yml | 125 +++++ deployment/playbooks/21-acme-persistence.yml | 177 ++++++ deployment/playbooks/30-wrpac-profile.yml | 75 +++ .../playbooks/31-wrpac-smoke-direct.yml | 194 +++++++ deployment/playbooks/40-wrpac-facade.yml | 184 +++++++ deployment/playbooks/99-smoke-test.yml | 106 ++++ deployment/requirements.txt | 1 + deployment/requirements.yml | 6 + deployment/site.yml | 10 + deployment/test-out/dogtag-ca-root.pem | 27 + deployment/test-out/phase3-1779199136.pem | 26 + deployment/test-out/phase3-1779199294.pem | 26 + deployment/test-out/phase3-1779201344.pem | 26 + deployment/test-out/phase3-1779202392.pem | 26 + deployment/test-out/profile-check.pem | 26 + deployment/tests/README.md | 45 ++ deployment/tests/test_01_directory.yml | 82 +++ deployment/tests/test_02_phase1_acme.yml | 61 ++ deployment/tests/test_03_phase2_direct.yml | 67 +++ deployment/tests/test_04_phase3_happy.yml | 43 ++ deployment/tests/test_05_phase3_negative.yml | 31 ++ deployment/tests/test_06_cert_profile.yml | 120 ++++ deployment/tests/test_07_persistence.yml | 86 +++ .../tests/test_08_facade_persistence.yml | 111 ++++ deployment/tests/test_all.yml | 12 + 49 files changed, 3811 insertions(+) create mode 100644 CLAUDE.md create mode 100644 deployment/.gitignore create mode 100644 deployment/README.md create mode 100644 deployment/ansible.cfg create mode 100644 deployment/files/wrpac-acme/pyproject.toml create mode 100644 deployment/files/wrpac-acme/wrpac_acme.egg-info/PKG-INFO create mode 100644 deployment/files/wrpac-acme/wrpac_acme.egg-info/SOURCES.txt create mode 100644 deployment/files/wrpac-acme/wrpac_acme.egg-info/dependency_links.txt create mode 100644 deployment/files/wrpac-acme/wrpac_acme.egg-info/requires.txt create mode 100644 deployment/files/wrpac-acme/wrpac_acme.egg-info/top_level.txt create mode 100644 deployment/files/wrpac-acme/wrpac_acme/__init__.py create mode 100644 deployment/files/wrpac-acme/wrpac_acme/client.py create mode 100644 deployment/files/wrpac-acme/wrpac_acme/crypto.py create mode 100644 deployment/files/wrpac-acme/wrpac_acme/dogtag.py create mode 100644 deployment/files/wrpac-acme/wrpac_acme/facade.py create mode 100644 deployment/files/wrpac-acme/wrpac_acme/negative_tests.py create mode 100644 deployment/files/wrpac-acme/wrpac_acme/rp_list.py create mode 100644 deployment/files/wrpac-acme/wrpac_acme/storage.py create mode 100644 deployment/files/wrpacCert.cfg.j2 create mode 100644 deployment/group_vars/all.yml create mode 100644 deployment/inventory.yml create mode 100644 deployment/nginx/ca-public.conf create mode 100644 deployment/playbooks/00-prereqs.yml create mode 100644 deployment/playbooks/10-dogtag-ca.yml create mode 100644 deployment/playbooks/20-dogtag-acme.yml create mode 100644 deployment/playbooks/21-acme-persistence.yml create mode 100644 deployment/playbooks/30-wrpac-profile.yml create mode 100644 deployment/playbooks/31-wrpac-smoke-direct.yml create mode 100644 deployment/playbooks/40-wrpac-facade.yml create mode 100644 deployment/playbooks/99-smoke-test.yml create mode 100644 deployment/requirements.txt create mode 100644 deployment/requirements.yml create mode 100644 deployment/site.yml create mode 100644 deployment/test-out/dogtag-ca-root.pem create mode 100644 deployment/test-out/phase3-1779199136.pem create mode 100644 deployment/test-out/phase3-1779199294.pem create mode 100644 deployment/test-out/phase3-1779201344.pem create mode 100644 deployment/test-out/phase3-1779202392.pem create mode 100644 deployment/test-out/profile-check.pem create mode 100644 deployment/tests/README.md create mode 100644 deployment/tests/test_01_directory.yml create mode 100644 deployment/tests/test_02_phase1_acme.yml create mode 100644 deployment/tests/test_03_phase2_direct.yml create mode 100644 deployment/tests/test_04_phase3_happy.yml create mode 100644 deployment/tests/test_05_phase3_negative.yml create mode 100644 deployment/tests/test_06_cert_profile.yml create mode 100644 deployment/tests/test_07_persistence.yml create mode 100644 deployment/tests/test_08_facade_persistence.yml create mode 100644 deployment/tests/test_all.yml diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..e238bb85 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,50 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Repository purpose + +This is a **documentation and governance** repository for the WE BUILD consortium's WP4 Architecture Group — it contains no application code. Contributions are Markdown documents that flow through a defined governance process and are compiled into a published deliverable (D4.1). + +Three artifact types, each with its own lifecycle and template: + +- **ADRs** ([adr/](adr/)) — lightweight Architecture Decision Records, one per software decision affecting interoperability. Template: [adr/_template.md](adr/_template.md). New ADRs must be added to the numbered list in [adr/README.md](adr/README.md) between the `` / `` markers — that list is the source of truth the blueprint build reads from. +- **Conformance Specifications (WBCS / CSs)** ([conformance-specs/](conformance-specs/)) — normative implementation requirements translating ADR decisions into testable specs (RFC 2119 language). Template: [conformance-specs/_template.md](conformance-specs/_template.md). Approved CSs go in the "Approved WBCSs" table in [conformance-specs/README.md](conformance-specs/README.md); the build reads CS filenames from any `.md)` link in that README. +- **Blueprint (D4.1)** ([blueprint/](blueprint/)) — the consolidated deliverable. Authored as numbered chapter files (`01-executive-summary.md` … `09-roadmap.md`) plus `appendix-*.md` files; the build assembles these together with the ADR and CS appendices into a single HTML and PDF. + +Drafts that aren't yet mature live in [webuild-drafts/](webuild-drafts/). + +## Building the Blueprint + +The build is a multi-stage shell pipeline in [blueprint/build.sh](blueprint/build.sh) that assembles every chapter + every ADR + every CS into one Markdown file, converts to AsciiDoc via `kramdoc`, and renders HTML and PDF via `asciidoctor` / `asciidoctor-pdf`. It is run from inside `blueprint/`: + +```sh +cd blueprint +./build.sh # incremental local build (assumes deps already installed) +./build.sh --github-action # also runs setup() to install Ruby, Python, Mermaid CLI, Chrome +``` + +Outputs land in `build_outputs_folder/blueprint/` (`blueprint.html`, `blueprint.pdf`, plus copied images). The `.venv/` Python environment is created on first `--github-action` run and reactivated on subsequent runs. + +CI ([.github/workflows/build-blueprint.yml](.github/workflows/build-blueprint.yml)) runs the build on push to `main`, `asciidoc`, or `blueprint-preview` and deploys to GitHub Pages. Published at: +- https://webuild-consortium.github.io/wp4-architecture/blueprint/blueprint.html +- https://webuild-consortium.github.io/wp4-architecture/blueprint/blueprint.pdf + +Dependencies (only needed when building locally): +- Ruby gems: `kramdown-asciidoc`, `asciidoctor-diagram`, `asciidoctor-pdf` (see [blueprint/Gemfile](blueprint/Gemfile)) +- Python (in `blueprint/.venv`): `enumerate-markdown`, `mistune==2.0.5` (see [requirements.txt](requirements.txt)) — provides the `markdown-enum` command used to number sections +- Node: `@mermaid-js/mermaid-cli` plus a pinned Chrome (`chrome@145.0.7632.46`) for diagram rendering + +## Things that will trip you up in the build + +- **The QTSP appendix is fetched from a sibling repo at build time** — `collect_qtsp_appendix` in [blueprint/build.sh](blueprint/build.sh) downloads `webuild-consortium/wp4-qtsp-group` as a zip and stitches READMEs together into `appendix-qtsp.md`. Don't commit `appendix-qtsp.md`, `qtsp-main.zip`, or the unpacked `wp4-qtsp-group-main/` directory. +- **ADR and CS appendices are auto-included from the `README.md` files in those directories.** To add an ADR or CS to the blueprint, edit the relevant README — don't touch `build.sh`. The build extracts `.md)` link targets from `adr/README.md` and `conformance-specs/README.md`. +- **Header levels get shifted** during inclusion (`indent_headers` is applied twice to each ADR/CS so a top-level `#` in the source becomes `###` in the blueprint). Author ADR/CS files as standalone documents with normal `#` headings; do not pre-shift. +- **The CS `_template.md` is escaped specially** — `<` and `>` are replaced with `<` / `>` before inclusion so AsciiDoc doesn't interpret placeholder angle brackets as tags. This is handled in `build.sh`; the template itself uses literal `<` / `>`. +- **Table column widths are patched post-conversion** via `table_width` in `build.sh`, keyed on the header text of specific tables in `main.adoc`. If you change those header rows in the source Markdown, also update the matching `fgrep` strings in `build.sh`. + +## Governance + +- All files are reviewed by `@webuild-consortium/blueprint-coordination-group` ([.github/CODEOWNERS](.github/CODEOWNERS)). +- Workflow: open an issue → discuss → PR → BCG review and merge. The ADR process diagram lives in [adr/README.md](adr/README.md). +- Once an ADR is merged it is the consortium's decision; dissenting views are captured in the **Advice** section of the ADR rather than blocking the merge. diff --git a/deployment/.gitignore b/deployment/.gitignore new file mode 100644 index 00000000..9018780f --- /dev/null +++ b/deployment/.gitignore @@ -0,0 +1,5 @@ +.venv/ +*.retry +collections/ +roles/galaxy/ +__pycache__/ diff --git a/deployment/README.md b/deployment/README.md new file mode 100644 index 00000000..b69d0eb8 --- /dev/null +++ b/deployment/README.md @@ -0,0 +1,150 @@ +# WE BUILD RPAC CA — Ansible deployment + +> **Scope.** This directory contains the **deployment artefacts** for a Dogtag PKI CA +> targeted at `mw-vm-ca01.lab` for WE BUILD interoperability testing of +> [CS-06](../conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md). +> +> It is **not** governance documentation. It is also **not yet** a CS-06-conformant +> deployment — see "Remaining gaps" below. + +## Phases delivered + +**Phase 1** — stock RFC 8555 ACME endpoint backed by Dogtag PKI on `mw-vm-ca01.lab`, +verified end-to-end with `acme.sh` (http-01 challenge, issues from the stock +`acmeServerCert` profile: RSA-only, 90-day). + +**Phase 2** — custom `wrpacCert` profile that matches CS-06 §7.4 (1-year validity, +RSA-3072+/ECDSA P-256/P-384, SHA-256+, Subject DN constraint requiring +`organizationIdentifier=`, WRPAC policy OID, SAN). Verified by direct enrolment +of a CSR with a real WRPAC Subject DN (`O=` legal name + `organizationIdentifier=` ++ `C=` + `CN=` friendly name) using the `pki` CLI as a CA agent. + +**Phase 3** — minimal CS-06 ACME façade (Python/FastAPI) + mock Registrar (RP List +service) sitting in front of the Dogtag CA. Implements `wrp-id` identifier +(CS-06 §5.2), `registrar-api-01` challenge (§5.3), EBW-EAB **stub** (§5.4 MVP +path — accepts any EAB blob without HMAC verification), and synchronous +finalize → CSR-Subject vs RP-List cross-check → `pki ca-cert-request-submit +--profile wrpacCert`. Verified end-to-end by a Python ACME client (`acme.sh` +can't speak `wrp-id`). + +## Layout + +``` +deployment/ +├── ansible.cfg base config (inventory, ssh) +├── inventory.yml single host: mw-vm-ca01.lab as cbadmin +├── group_vars/all.yml CA FQDN, passwords (test-only), DS suffix, +│ WRPAC test identity, placeholder policy OIDs +├── site.yml imports the playbooks in order +├── files/ +│ ├── wrpacCert.cfg.j2 Dogtag profile template (CS-06 §7.4) +│ └── wrpac-acme/ Phase 3 Python project (façade + RP List + client) +│ ├── pyproject.toml +│ └── wrpac_acme/ +│ ├── rp_list.py Mock Registrar / RP List service (port 9000) +│ ├── facade.py ACME façade (port 9080) +│ ├── crypto.py JWS / JWK / key-authorization helpers +│ ├── dogtag.py Submits CSRs to Dogtag via `pki` CLI +│ └── client.py End-to-end Python ACME client (CS-06 §6 flow) +└── playbooks/ + ├── 00-prereqs.yml hostname, EPEL, firewalld, time sync, repos + ├── 10-dogtag-ca.yml 389-DS instance + pkispawn -s CA + ├── 20-dogtag-acme.yml pki-acme package + acme-create/acme-deploy + ├── 21-acme-persistence.yml switch Dogtag ACME backend from in-memory to 389-DS + ├── 30-wrpac-profile.yml install + register wrpacCert profile + ├── 31-wrpac-smoke-direct.yml direct CSR enrolment with WRPAC Subject DN + ├── 40-wrpac-facade.yml sync façade + RP List, systemd units, e2e smoke + └── 99-smoke-test.yml GET /acme/directory + acme.sh test issuance + +tests/ +├── README.md how to run; var reference +├── test_all.yml master — runs all six in order +├── test_01_directory.yml directories + RP List sanity [URL-flexible] +├── test_02_phase1_acme.yml acme.sh stock ACME http-01 issuance [local only] +├── test_03_phase2_direct.yml pki CLI direct enrolment with wrpacCert [local only] +├── test_04_phase3_happy.yml Python ACME client CS-06 §6 happy path [URL-flexible] +├── test_05_phase3_negative.yml CS-06 §7.2 MUST negative-test battery [URL-flexible] +├── test_06_cert_profile.yml Issue a fresh WRPAC + assert every §7.4 attribute [URL-flexible] +├── test_07_persistence.yml Dogtag ACME state survives pki-tomcatd restart [local only] +└── test_08_facade_persistence.yml CS-06 façade + RP List survive service restart [local only] +``` + +## Running the test suite + +```sh +# Against the local deployment (default) +ansible-playbook tests/test_all.yml + +# Against the public deployment +ansible-playbook tests/test_all.yml \ + -e test_acme_directory=https://rpca.lab.cleverbase.io/acme-eudi-wrpac/directory \ + -e test_rp_list=https://rpca.lab.cleverbase.io \ + -e test_acme_stock_directory=https://rpca.lab.cleverbase.io/acme/directory +``` + +Tests 02 and 03 always run on the CA host (they need box-local state: acme.sh's +cache, the pki NSS DB). Tests 01, 04, 05, 06 run from the control machine, so +they can target either internal or public URLs. + +## Run + +From this directory: + +```sh +python3 -m venv .venv +. .venv/bin/activate +pip install -r requirements.txt +ansible-galaxy collection install -r requirements.yml + +# Connectivity check +ansible -i inventory.yml -m ping all + +# Full deploy +ansible-playbook site.yml + +# Or step-by-step +ansible-playbook playbooks/00-prereqs.yml +ansible-playbook playbooks/10-dogtag-ca.yml +ansible-playbook playbooks/20-dogtag-acme.yml +ansible-playbook playbooks/99-smoke-test.yml +``` + +## Passwords + +Test-only passwords live in [group_vars/all.yml](group_vars/all.yml) (`Secret.123`). +Before any non-throwaway use, replace them with an `ansible-vault` encrypted file. + +## Remaining gaps before this is CS-06 conformant + +### Implemented (Phase 1–3) + +- `wrp-id` identifier type (§5.2) — façade-level +- `registrar-api-01` challenge (§5.3) — façade fetches RP List, verifies `acme-challenge` against expected key-authorization +- WRPAC certificate profile (§7.4) — Dogtag `wrpacCert`, 1-year, RSA-3072+/ECDSA P-256/P-384, policy OID, server-built Subject DN +- Mock Registrar / RP List (§7.8) — single-instance shorthand form +- EAB **structure** at newAccount (§5.4) — blob accepted, HMAC not verified + +### Still missing + +| Gap | Spec ref | Notes | +|---|---|---| +| HTTPS on the façade | §7.1 #2 | Façade currently binds plain HTTP on :9080. Put nginx in front, or have uvicorn terminate TLS with a Dogtag-issued cert. | +| Real EAB HMAC verification | §5.4 | Currently a stub. Needs an EAB-credentials store (Key ID → HMAC key) populated by the RA, and JWS HMAC verify on newAccount. | +| Nonce replay protection | §7.1 #4 | Façade issues fresh nonces but does not verify incoming ones. | +| `keyChange`, real `revokeCert` | §5.1, §7.6 | Stubs / unimplemented. | +| Multi-instance `instanceId` | §5.2, §8.3 | Single-instance only today. | +| `registrationCertificate` URL co-issuance | §5.1, §8.5 | The order object never carries this URL. | +| CT logging + SCTs | §7.5 | Neither Dogtag nor the façade log to CT today. | +| ~~Persistent state (Phase 3 façade)~~ | — | ✓ Done. Phase 3 façade state (accounts/orders/authz/challenges/certs) + mock RP List persist in SQLite under `/var/lib/wrpac-acme/`. See [`wrpac_acme/storage.py`](files/wrpac-acme/wrpac_acme/storage.py). Dogtag's own ACME state persists in 389-DS — see [21-acme-persistence.yml](playbooks/21-acme-persistence.yml). | +| Verified WRPAC policy OIDs | §7.4 #2 | Values in `group_vars/all.yml` are **placeholders** in the ETSI test arc. Replace with `NCP-l-eudiwrp` / `NCP-n-eudiwrp` from ETSI TS 119 411-8 v1.1.1. | +| Full OID4VP-based EBW authentication (MVP+) | §5.4, §6.2 | The RA + EBW flow that provisions EAB credentials is out of scope. | + +`99-smoke-test.yml` (Phase 1 stock-ACME smoke test against Dogtag's built-in +responder) is still wired up alongside the Phase 3 façade. They live on +different ports (8443 vs 9080) and don't interfere. + +## Tear-down + +The CA can be removed with `pkidestroy -s CA -i pki-tomcat` and the 389-DS +instance with `dsctl pki remove --do-it`. A tear-down playbook is not provided — +re-image the VM if you want a clean slate. diff --git a/deployment/ansible.cfg b/deployment/ansible.cfg new file mode 100644 index 00000000..b3b6baec --- /dev/null +++ b/deployment/ansible.cfg @@ -0,0 +1,11 @@ +[defaults] +inventory = inventory.yml +host_key_checking = True +stdout_callback = default +result_format = yaml +forks = 5 +retry_files_enabled = False +interpreter_python = auto_silent + +[ssh_connection] +pipelining = True diff --git a/deployment/files/wrpac-acme/pyproject.toml b/deployment/files/wrpac-acme/pyproject.toml new file mode 100644 index 00000000..b420650c --- /dev/null +++ b/deployment/files/wrpac-acme/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "wrpac-acme" +version = "0.1.0" +description = "WE BUILD CS-06 ACME façade + mock RP List service for Dogtag PKI" +requires-python = ">=3.9" +dependencies = [ + "fastapi>=0.110,<0.116", + "uvicorn[standard]>=0.27,<0.32", + "pydantic>=2.5,<3", + "cryptography>=42,<44", + "jwcrypto>=1.5,<2", + "httpx>=0.26,<0.28", +] + +[build-system] +requires = ["setuptools>=64", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["."] +include = ["wrpac_acme*"] diff --git a/deployment/files/wrpac-acme/wrpac_acme.egg-info/PKG-INFO b/deployment/files/wrpac-acme/wrpac_acme.egg-info/PKG-INFO new file mode 100644 index 00000000..93508426 --- /dev/null +++ b/deployment/files/wrpac-acme/wrpac_acme.egg-info/PKG-INFO @@ -0,0 +1,11 @@ +Metadata-Version: 2.4 +Name: wrpac-acme +Version: 0.1.0 +Summary: WE BUILD CS-06 ACME façade + mock RP List service for Dogtag PKI +Requires-Python: >=3.9 +Requires-Dist: fastapi<0.116,>=0.110 +Requires-Dist: uvicorn[standard]<0.32,>=0.27 +Requires-Dist: pydantic<3,>=2.5 +Requires-Dist: cryptography<44,>=42 +Requires-Dist: jwcrypto<2,>=1.5 +Requires-Dist: httpx<0.28,>=0.26 diff --git a/deployment/files/wrpac-acme/wrpac_acme.egg-info/SOURCES.txt b/deployment/files/wrpac-acme/wrpac_acme.egg-info/SOURCES.txt new file mode 100644 index 00000000..7f751655 --- /dev/null +++ b/deployment/files/wrpac-acme/wrpac_acme.egg-info/SOURCES.txt @@ -0,0 +1,13 @@ +pyproject.toml +wrpac_acme/__init__.py +wrpac_acme/client.py +wrpac_acme/crypto.py +wrpac_acme/dogtag.py +wrpac_acme/facade.py +wrpac_acme/negative_tests.py +wrpac_acme/rp_list.py +wrpac_acme.egg-info/PKG-INFO +wrpac_acme.egg-info/SOURCES.txt +wrpac_acme.egg-info/dependency_links.txt +wrpac_acme.egg-info/requires.txt +wrpac_acme.egg-info/top_level.txt \ No newline at end of file diff --git a/deployment/files/wrpac-acme/wrpac_acme.egg-info/dependency_links.txt b/deployment/files/wrpac-acme/wrpac_acme.egg-info/dependency_links.txt new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/deployment/files/wrpac-acme/wrpac_acme.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/deployment/files/wrpac-acme/wrpac_acme.egg-info/requires.txt b/deployment/files/wrpac-acme/wrpac_acme.egg-info/requires.txt new file mode 100644 index 00000000..c09fde1b --- /dev/null +++ b/deployment/files/wrpac-acme/wrpac_acme.egg-info/requires.txt @@ -0,0 +1,6 @@ +fastapi<0.116,>=0.110 +uvicorn[standard]<0.32,>=0.27 +pydantic<3,>=2.5 +cryptography<44,>=42 +jwcrypto<2,>=1.5 +httpx<0.28,>=0.26 diff --git a/deployment/files/wrpac-acme/wrpac_acme.egg-info/top_level.txt b/deployment/files/wrpac-acme/wrpac_acme.egg-info/top_level.txt new file mode 100644 index 00000000..70d92af6 --- /dev/null +++ b/deployment/files/wrpac-acme/wrpac_acme.egg-info/top_level.txt @@ -0,0 +1 @@ +wrpac_acme diff --git a/deployment/files/wrpac-acme/wrpac_acme/__init__.py b/deployment/files/wrpac-acme/wrpac_acme/__init__.py new file mode 100644 index 00000000..98489472 --- /dev/null +++ b/deployment/files/wrpac-acme/wrpac_acme/__init__.py @@ -0,0 +1 @@ +"""WE BUILD CS-06 ACME façade + mock RP List service.""" diff --git a/deployment/files/wrpac-acme/wrpac_acme/client.py b/deployment/files/wrpac-acme/wrpac_acme/client.py new file mode 100644 index 00000000..a4c48255 --- /dev/null +++ b/deployment/files/wrpac-acme/wrpac_acme/client.py @@ -0,0 +1,243 @@ +"""End-to-end Python ACME client speaking CS-06 (wrp-id + registrar-api-01). + +Simulates a WRP/EBW issuing itself a WRPAC. + +Usage: + python -m wrpac_acme.client \ + --directory http://mw-vm-ca01.lab:9080/acme-eudi-wrpac/directory \ + --rp-list http://mw-vm-ca01.lab:9000 \ + --wrp-id NLKVK.12345678 \ + --legal-name "ACME Test Organisation B.V." \ + --org-id VATNL-000000000B01 \ + --country NL \ + --friendly-name "ACME RP Smoke Test" +""" +from __future__ import annotations + +import argparse +import base64 +import json +import sys +import time +from typing import Any, Dict, Optional + +import httpx +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID +from jwcrypto import jwk, jws + + +def b64u(b: bytes) -> str: + return base64.urlsafe_b64encode(b).rstrip(b"=").decode("ascii") + + +def sign(key: jwk.JWK, protected: Dict[str, Any], payload: bytes) -> Dict[str, str]: + token = jws.JWS(payload) + token.add_signature(key, alg=protected.get("alg", "RS256"), + protected=json.dumps(protected)) + return json.loads(token.serialize()) + + +class ACMEClient: + def __init__(self, directory_url: str, rp_list_url: str) -> None: + self.directory_url = directory_url + self.rp_list_url = rp_list_url.rstrip("/") + self.http = httpx.Client(timeout=15.0, verify=False) + self.directory: Dict[str, Any] = {} + self.nonce: Optional[str] = None + self.account_key = jwk.JWK.generate(kty="RSA", size=3072) + self.account_url: Optional[str] = None + + # ----- low-level helpers ----- + + def fetch_directory(self) -> None: + r = self.http.get(self.directory_url) + r.raise_for_status() + self.directory = r.json() + print("[client] directory:", json.dumps(self.directory, indent=2)) + + def fetch_nonce(self) -> None: + r = self.http.head(self.directory["newNonce"]) + r.raise_for_status() + self.nonce = r.headers["Replay-Nonce"] + + def _absorb_nonce(self, r: httpx.Response) -> None: + n = r.headers.get("Replay-Nonce") + if n: + self.nonce = n + + def _post(self, url: str, *, payload: Optional[Dict[str, Any]] = None, + use_jwk: bool = False, extra_protected: Optional[Dict[str, Any]] = None) -> httpx.Response: + if self.nonce is None: + self.fetch_nonce() + protected: Dict[str, Any] = {"alg": "RS256", "nonce": self.nonce, "url": url} + if use_jwk: + protected["jwk"] = json.loads(self.account_key.export_public()) + else: + assert self.account_url is not None + protected["kid"] = self.account_url + if extra_protected: + protected.update(extra_protected) + + payload_bytes = b"" if payload is None else json.dumps(payload).encode("utf-8") + body = sign(self.account_key, protected, payload_bytes) + r = self.http.post(url, json=body, + headers={"Content-Type": "application/jose+json"}) + self._absorb_nonce(r) + return r + + # ----- ACME flow ----- + + def new_account_with_eab(self, contact: list[str], wrp_id: str) -> None: + # Stub EAB: a non-empty JWS-shaped blob the façade will accept. + eab_protected = b64u(json.dumps({"alg": "HS256", "kid": f"eab-{wrp_id}", + "url": self.directory["newAccount"]}).encode()) + eab_payload = b64u(self.account_key.export_public().encode()) + eab_signature = b64u(b"STUB-NOT-AN-HMAC") # CS-06 §5.4 MVP path + payload = { + "termsOfServiceAgreed": True, + "contact": contact, + "externalAccountBinding": { + "protected": eab_protected, + "payload": eab_payload, + "signature": eab_signature, + }, + } + r = self._post(self.directory["newAccount"], payload=payload, use_jwk=True) + if r.status_code not in (200, 201): + raise RuntimeError(f"newAccount failed: {r.status_code} {r.text}") + self.account_url = r.headers["Location"] + print("[client] account:", self.account_url) + + def new_order(self, wrp_id: str) -> Dict[str, Any]: + r = self._post(self.directory["newOrder"], + payload={"identifiers": [{"type": "wrp-id", "value": wrp_id}]}) + if r.status_code not in (200, 201): + raise RuntimeError(f"newOrder failed: {r.status_code} {r.text}") + order = r.json() + order["_url"] = r.headers["Location"] + print("[client] order:", json.dumps(order, indent=2)) + return order + + def get_authz(self, authz_url: str) -> Dict[str, Any]: + r = self._post(authz_url, payload=None) # POST-as-GET + r.raise_for_status() + return r.json() + + def post_challenge(self, chall_url: str) -> Dict[str, Any]: + r = self._post(chall_url, payload={}) + if r.status_code not in (200, 202): + raise RuntimeError(f"challenge POST failed: {r.status_code} {r.text}") + return r.json() + + def finalize(self, finalize_url: str, csr_der: bytes) -> Dict[str, Any]: + r = self._post(finalize_url, payload={"csr": b64u(csr_der)}) + if r.status_code not in (200, 201): + raise RuntimeError(f"finalize failed: {r.status_code} {r.text}") + return r.json() + + def get_order(self, order_url: str) -> Dict[str, Any]: + r = self._post(order_url, payload=None) + r.raise_for_status() + return r.json() + + def download_cert(self, cert_url: str) -> str: + r = self._post(cert_url, payload=None) + r.raise_for_status() + return r.text + + # ----- RP List helpers (out-of-band, not part of ACME) ----- + + def put_acme_challenge(self, wrp_id: str, key_auth: str) -> None: + r = self.http.put(f"{self.rp_list_url}/rp-list/{wrp_id}/acme-challenge", + json={"key_authorization": key_auth}) + r.raise_for_status() + + +def build_csr(legal_name: str, org_id: str, country: str, + friendly_name: str) -> tuple[bytes, rsa.RSAPrivateKey]: + key = rsa.generate_private_key(public_exponent=65537, key_size=3072) + subject = x509.Name([ + x509.NameAttribute(NameOID.COUNTRY_NAME, country), + x509.NameAttribute(x509.ObjectIdentifier("2.5.4.97"), org_id), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, legal_name), + x509.NameAttribute(NameOID.COMMON_NAME, friendly_name), + ]) + csr = x509.CertificateSigningRequestBuilder() \ + .subject_name(subject) \ + .add_extension(x509.SubjectAlternativeName([x509.DNSName("rp.test.lab")]), + critical=False) \ + .sign(key, hashes.SHA256()) + return csr.public_bytes(serialization.Encoding.DER), key + + +def jwk_thumbprint(key: jwk.JWK) -> str: + return key.thumbprint() + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--directory", required=True) + ap.add_argument("--rp-list", required=True) + ap.add_argument("--wrp-id", required=True) + ap.add_argument("--legal-name", required=True) + ap.add_argument("--org-id", required=True) + ap.add_argument("--country", default="NL") + ap.add_argument("--friendly-name", required=True) + ap.add_argument("--out-cert", default="/tmp/wrpac-issued.pem") + args = ap.parse_args() + + c = ACMEClient(args.directory, args.rp_list) + c.fetch_directory() + c.fetch_nonce() + c.new_account_with_eab([f"mailto:smoke@{args.wrp_id}"], args.wrp_id) + order = c.new_order(args.wrp_id) + + # Walk authorizations + for authz_url in order["authorizations"]: + authz = c.get_authz(authz_url) + print("[client] authz:", json.dumps(authz, indent=2)) + chall = next(ch for ch in authz["challenges"] if ch["type"] == "registrar-api-01") + key_auth = f"{chall['token']}.{jwk_thumbprint(c.account_key)}" + print(f"[client] placing key-auth in RP List for {args.wrp_id}") + c.put_acme_challenge(args.wrp_id, key_auth) + print("[client] signalling readiness on challenge URL") + c.post_challenge(chall["url"]) + # Poll authz until valid (façade transitions synchronously, so one poll suffices) + for _ in range(10): + authz = c.get_authz(authz_url) + if authz["status"] == "valid": + break + if authz["status"] == "invalid": + print("[client] authz invalid:", authz, file=sys.stderr) + return 1 + time.sleep(1) + + csr_der, _key = build_csr(args.legal_name, args.org_id, args.country, args.friendly_name) + order2 = c.finalize(order["finalize"], csr_der) + print("[client] order after finalize:", json.dumps(order2, indent=2)) + + if "certificate" not in order2: + # Poll until certificate URL appears + for _ in range(10): + order2 = c.get_order(order["_url"]) + if order2.get("certificate"): + break + time.sleep(1) + else: + print("[client] no certificate URL appeared", file=sys.stderr) + return 1 + + pem = c.download_cert(order2["certificate"]) + with open(args.out_cert, "w") as f: + f.write(pem) + print(f"[client] issued WRPAC written to {args.out_cert}") + print("---- PEM ----") + print(pem) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/deployment/files/wrpac-acme/wrpac_acme/crypto.py b/deployment/files/wrpac-acme/wrpac_acme/crypto.py new file mode 100644 index 00000000..90f4e9cd --- /dev/null +++ b/deployment/files/wrpac-acme/wrpac_acme/crypto.py @@ -0,0 +1,92 @@ +"""JWS / JWK helpers used by the ACME façade. + +Just enough crypto to: + - parse and verify a flattened JWS request body (RFC 8555 §6.2) + - compute the JWK thumbprint per RFC 7638 + - derive the key-authorization per RFC 8555 §8.1 + +For Phase 3 simplicity we DO NOT verify nonces here — the façade just issues +fresh ones and never checks. Replay protection is out of scope for the +interop demo. +""" +from __future__ import annotations + +import base64 +import json +from dataclasses import dataclass +from typing import Optional + +from jwcrypto import jwk, jws + + +def b64u(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def b64u_decode(data: str) -> bytes: + padding = "=" * (-len(data) % 4) + return base64.urlsafe_b64decode(data + padding) + + +@dataclass +class ParsedJWS: + protected: dict + payload: bytes + signature_valid: bool + jwk_obj: jwk.JWK # The verifying key + payload_json: Optional[dict] = None + + +def parse_and_verify( + body: dict, + *, + account_jwk: Optional[jwk.JWK] = None, +) -> ParsedJWS: + """Parse a flattened JWS request body and verify the signature. + + If `account_jwk` is given, that's the key the JWS must verify against + (post-newAccount requests use `kid`). Otherwise the JWS protected + header MUST carry an embedded `jwk` (newAccount, revokeCert by cert key). + """ + if not all(k in body for k in ("protected", "payload", "signature")): + raise ValueError("not a flattened JWS") + + protected_raw = b64u_decode(body["protected"]) + protected = json.loads(protected_raw) + + if account_jwk is None: + if "jwk" not in protected: + raise ValueError("no jwk in protected header and no account key supplied") + verify_key = jwk.JWK(**protected["jwk"]) + else: + verify_key = account_jwk + + token = jws.JWS() + token.deserialize(json.dumps(body)) + token.verify(verify_key) + + payload = token.payload # bytes + payload_json: Optional[dict] = None + if payload: + try: + payload_json = json.loads(payload) + except json.JSONDecodeError: + payload_json = None + + return ParsedJWS( + protected=protected, + payload=payload, + signature_valid=True, + jwk_obj=verify_key, + payload_json=payload_json, + ) + + +def jwk_thumbprint(key: jwk.JWK) -> str: + """RFC 7638 JWK thumbprint, returned as base64url-no-pad.""" + return key.thumbprint() + + +def key_authorization(token: str, key: jwk.JWK) -> str: + """RFC 8555 §8.1: token || '.' || base64url(Thumbprint(accountKey)).""" + return f"{token}.{jwk_thumbprint(key)}" diff --git a/deployment/files/wrpac-acme/wrpac_acme/dogtag.py b/deployment/files/wrpac-acme/wrpac_acme/dogtag.py new file mode 100644 index 00000000..1d310af0 --- /dev/null +++ b/deployment/files/wrpac-acme/wrpac_acme/dogtag.py @@ -0,0 +1,91 @@ +"""Thin wrapper around the `pki` CLI to submit a CSR to Dogtag. + +The façade reuses the NSS DB set up by Phase 2's direct-enrolment smoke test +(/root/wrpac-smoke/nssdb) which already contains the caadmin client cert and +the trusted CA signing cert. +""" +from __future__ import annotations + +import asyncio +import os +import re +import subprocess +import tempfile +from dataclasses import dataclass + + +@dataclass +class DogtagConfig: + nss_dir: str = os.getenv("WRPAC_NSS_DIR", "/root/wrpac-smoke/nssdb") + nss_password: str = os.getenv("WRPAC_NSS_PASSWORD", "Secret.123") + admin_nickname: str = os.getenv("WRPAC_ADMIN_NICKNAME", "PKI Administrator for lab") + ca_url: str = os.getenv("WRPAC_CA_URL", "https://mw-vm-ca01.lab:8443") + profile_id: str = os.getenv("WRPAC_PROFILE_ID", "wrpacCert") + + +_CFG = DogtagConfig() + + +def _pki_base() -> list[str]: + return [ + "pki", + "-d", _CFG.nss_dir, + "-c", _CFG.nss_password, + "-n", _CFG.admin_nickname, + "-U", _CFG.ca_url, + ] + + +_CERT_ID_RE = re.compile(r"Certificate ID:\s*(\S+)") + + +def _sync_submit_csr(csr_pem: bytes) -> str: + """Submit a PEM-encoded CSR via the pki CLI and return the issued cert (PEM).""" + with tempfile.TemporaryDirectory() as tmp: + csr_path = os.path.join(tmp, "req.csr") + cert_path = os.path.join(tmp, "cert.pem") + with open(csr_path, "wb") as f: + f.write(csr_pem) + + submit = subprocess.run( + _pki_base() + [ + "ca-cert-request-submit", + "--profile", _CFG.profile_id, + "--csr-file", csr_path, + ], + capture_output=True, + text=True, + ) + if submit.returncode != 0: + raise RuntimeError( + f"pki ca-cert-request-submit failed: rc={submit.returncode} " + f"stderr={submit.stderr.strip()} stdout={submit.stdout.strip()}" + ) + if "Request Status: complete" not in submit.stdout: + raise RuntimeError( + f"cert request not complete: {submit.stdout.strip()}" + ) + m = _CERT_ID_RE.search(submit.stdout) + if not m: + raise RuntimeError(f"no Certificate ID in output: {submit.stdout.strip()}") + cert_id = m.group(1) + + show = subprocess.run( + _pki_base() + [ + "ca-cert-show", cert_id, + "--output", cert_path, + ], + capture_output=True, + text=True, + ) + if show.returncode != 0: + raise RuntimeError( + f"pki ca-cert-show failed: rc={show.returncode} stderr={show.stderr.strip()}" + ) + with open(cert_path, "rb") as f: + return f.read().decode("ascii") + + +async def submit_csr(csr_pem: bytes) -> str: + """Async wrapper — runs the blocking pki CLI in a thread.""" + return await asyncio.to_thread(_sync_submit_csr, csr_pem) diff --git a/deployment/files/wrpac-acme/wrpac_acme/facade.py b/deployment/files/wrpac-acme/wrpac_acme/facade.py new file mode 100644 index 00000000..cce71189 --- /dev/null +++ b/deployment/files/wrpac-acme/wrpac_acme/facade.py @@ -0,0 +1,519 @@ +"""WE BUILD CS-06 ACME façade — minimal interop-test implementation. + +What's here +----------- +* /acme-eudi-wrpac/directory with `wrp-id` and `registrar-api-01` in meta +* JWS-signed requests per RFC 8555 §6 +* newAccount with EAB stub (CS-06 §5.4 MVP path — EAB blob required but not HMAC-verified) +* newOrder accepting `{type:"wrp-id", value: }` +* registrar-api-01 challenge: fetches RP List, compares key-authorization +* finalize: verifies CSR Subject DN matches the RP List entry, forwards CSR to + Dogtag (`pki ca-cert-request-submit --profile wrpacCert`), returns issued PEM + +What's deliberately out of scope (see deployment/README.md) +---------------------------------------------------------- +* HTTPS, nonce replay protection, real EAB HMAC, keyChange, multi-instance, + registrationCertificate co-issuance, CT logging +""" +from __future__ import annotations + +import json +import os +import secrets +import uuid +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +import httpx +from cryptography import x509 +from cryptography.hazmat.primitives.serialization import Encoding +from fastapi import FastAPI, HTTPException, Request, Response +from fastapi.responses import JSONResponse, PlainTextResponse +from jwcrypto import jwk + +from .crypto import key_authorization, parse_and_verify +from .dogtag import submit_csr +from .storage import Store + +ROOT = "/acme-eudi-wrpac" +RP_LIST_URL = os.getenv("WRPAC_RP_LIST_URL", "http://localhost:9000") +DB_PATH = os.getenv("WRPAC_DB_PATH", "/var/lib/wrpac-acme/facade.sqlite3") +# Optional fallback if the request doesn't carry usable Host/X-Forwarded-*. +BASE_URL_FALLBACK = os.getenv("WRPAC_BASE_URL", "http://mw-vm-ca01.lab:9080").rstrip("/") + +app = FastAPI(title="WE BUILD CS-06 ACME façade", version="0.1.0") + + +# ---------- Persistent state ---------- + +@dataclass +class Account: + id: str + jwk: jwk.JWK + contact: List[str] = field(default_factory=list) + status: str = "valid" + + +@dataclass +class Challenge: + id: str + type: str # "registrar-api-01" + token: str + status: str = "pending" + authz_id: str = "" + + +@dataclass +class Authorization: + id: str + order_id: str + identifier_type: str + identifier_value: str + status: str = "pending" + challenge_ids: List[str] = field(default_factory=list) + + +@dataclass +class Order: + id: str + account_id: str + identifiers: List[Dict[str, str]] + authz_ids: List[str] = field(default_factory=list) + status: str = "pending" + cert_id: Optional[str] = None + + +# Serialization helpers --------------------------------------------------- + +def _account_to_dict(a: Account) -> dict: + return { + "id": a.id, + "jwk": json.loads(a.jwk.export(private_key=False)), + "contact": list(a.contact), + "status": a.status, + } + + +def _account_from_dict(d: dict) -> Account: + return Account( + id=d["id"], + jwk=jwk.JWK(**d["jwk"]), + contact=list(d.get("contact", [])), + status=d.get("status", "valid"), + ) + + +def _challenge_to_dict(c: Challenge) -> dict: + return {"id": c.id, "type": c.type, "token": c.token, + "status": c.status, "authz_id": c.authz_id} + + +def _challenge_from_dict(d: dict) -> Challenge: + return Challenge(**d) + + +def _authz_to_dict(a: Authorization) -> dict: + return {"id": a.id, "order_id": a.order_id, + "identifier_type": a.identifier_type, + "identifier_value": a.identifier_value, + "status": a.status, + "challenge_ids": list(a.challenge_ids)} + + +def _authz_from_dict(d: dict) -> Authorization: + return Authorization(**d) + + +def _order_to_dict(o: Order) -> dict: + return {"id": o.id, "account_id": o.account_id, + "identifiers": list(o.identifiers), + "authz_ids": list(o.authz_ids), + "status": o.status, "cert_id": o.cert_id} + + +def _order_from_dict(d: dict) -> Order: + return Order(**d) + + +def _cert_to_dict(pem: str) -> dict: + return {"pem": pem} + + +def _cert_from_dict(d: dict) -> str: + return d["pem"] + + +_accounts: Store[Account] = Store(DB_PATH, "accounts", _account_to_dict, _account_from_dict) +_orders: Store[Order] = Store(DB_PATH, "orders", _order_to_dict, _order_from_dict) +_authzs: Store[Authorization] = Store(DB_PATH, "authzs", _authz_to_dict, _authz_from_dict) +_challenges: Store[Challenge] = Store(DB_PATH, "challenges", _challenge_to_dict, _challenge_from_dict) +_certs: Store[str] = Store(DB_PATH, "certs", _cert_to_dict, _cert_from_dict) + + +# ---------- URL helpers ---------- + +def base_url(request: Optional[Request]) -> str: + """Derive the public-facing base URL from the request. + + Honors X-Forwarded-Proto / Host when uvicorn is started with + --proxy-headers; otherwise reads scheme+host directly. Falls back to + BASE_URL_FALLBACK when no request is available (e.g. internal helpers + that don't have a request handle yet).""" + if request is None: + return BASE_URL_FALLBACK + return f"{request.url.scheme}://{request.url.netloc}".rstrip("/") + + +def u(request: Optional[Request], *path: str) -> str: + return f"{base_url(request)}{ROOT}/{'/'.join(path)}" + + +# ---------- Middleware: stamp every response with Replay-Nonce ---------- + +@app.middleware("http") +async def stamp_nonce(request: Request, call_next): + response = await call_next(request) + # Every ACME response should carry a fresh nonce (RFC 8555 §6.5). + response.headers["Replay-Nonce"] = secrets.token_urlsafe(24) + return response + + +# ---------- Directory & nonce ---------- + +@app.get(ROOT + "/directory") +def directory(request: Request) -> JSONResponse: + return JSONResponse({ + "newNonce": u(request, "new-nonce"), + "newAccount": u(request, "new-account"), + "newOrder": u(request, "new-order"), + "revokeCert": u(request, "revoke-cert"), + "meta": { + "termsOfService": f"{base_url(request)}/tos", + "externalAccountRequired": True, + "supportedIdentifierTypes": ["wrp-id"], + "supportedChallengeTypes": ["registrar-api-01"], + "rprcCoIssuanceSupported": False, + "multiInstanceIssuanceSupported": False, + }, + }) + + +@app.api_route(ROOT + "/new-nonce", methods=["GET", "HEAD"]) +def new_nonce() -> Response: + # The middleware adds the Replay-Nonce header for us. + # RFC 8555 §7.2: HEAD returns 200, GET returns 204. + return Response(status_code=204) + + +# ---------- JWS helpers ---------- + +async def _read_jws(request: Request) -> dict: + raw = await request.body() + try: + return __import__("json").loads(raw.decode("utf-8")) + except Exception: + raise HTTPException(400, "request body must be a flattened JWS") + + +def _verify_with_account(body: dict) -> tuple[Account, dict, bytes]: + """Verify a JWS where `kid` references an existing account URL.""" + import base64 + import json as _json + + if "protected" not in body: + raise HTTPException(400, "missing protected header") + pad = "=" * (-len(body["protected"]) % 4) + protected = _json.loads(base64.urlsafe_b64decode(body["protected"] + pad)) + kid = protected.get("kid") + if not kid: + raise HTTPException(400, "kid required for this endpoint") + # kid is the account URL: .../acct/ + account_id = kid.rstrip("/").rsplit("/", 1)[-1] + account = _accounts.get(account_id) + if account is None: + raise HTTPException(401, "unknown account") + parsed = parse_and_verify(body, account_jwk=account.jwk) + return account, parsed.protected, parsed.payload + + +# ---------- newAccount ---------- + +@app.post(ROOT + "/new-account") +async def new_account(request: Request) -> JSONResponse: + body = await _read_jws(request) + parsed = parse_and_verify(body, account_jwk=None) + payload = parsed.payload_json or {} + + # CS-06 §5.4: EAB required. We accept any well-formed EAB blob (stub). + eab = payload.get("externalAccountBinding") + if not eab or not all(k in eab for k in ("protected", "payload", "signature")): + raise HTTPException(400, "externalAccountBinding required (CS-06 §5.4)") + + account_id = uuid.uuid4().hex[:16] + _accounts[account_id] = Account( + id=account_id, + jwk=parsed.jwk_obj, + contact=payload.get("contact", []), + ) + resp = JSONResponse( + { + "status": "valid", + "contact": _accounts[account_id].contact, + "orders": u(request, "acct", account_id, "orders"), + }, + status_code=201, + ) + resp.headers["Location"] = u(request, "acct", account_id) + return resp + + +# ---------- newOrder ---------- + +@app.post(ROOT + "/new-order") +async def new_order(request: Request) -> JSONResponse: + body = await _read_jws(request) + account, _, _payload = _verify_with_account(body) + payload_json = __import__("json").loads(_payload.decode("utf-8")) if _payload else {} + + identifiers = payload_json.get("identifiers") or [] + if not identifiers or any(i.get("type") != "wrp-id" for i in identifiers): + raise HTTPException(400, "identifiers must contain at least one wrp-id (CS-06 §5.2)") + + order_id = uuid.uuid4().hex[:16] + authz_ids: List[str] = [] + for ident in identifiers: + authz_id = uuid.uuid4().hex[:16] + chall_id = uuid.uuid4().hex[:16] + token = secrets.token_urlsafe(24) + _challenges[chall_id] = Challenge( + id=chall_id, type="registrar-api-01", token=token, authz_id=authz_id, + ) + _authzs[authz_id] = Authorization( + id=authz_id, + order_id=order_id, + identifier_type=ident["type"], + identifier_value=ident["value"], + challenge_ids=[chall_id], + ) + authz_ids.append(authz_id) + + _orders[order_id] = Order( + id=order_id, + account_id=account.id, + identifiers=identifiers, + authz_ids=authz_ids, + ) + resp = JSONResponse(_order_view(request, order_id), status_code=201) + resp.headers["Location"] = u(request, "order", order_id) + return resp + + +def _order_view(request: Request, order_id: str) -> dict: + o = _orders[order_id] + view = { + "status": o.status, + "identifiers": o.identifiers, + "authorizations": [u(request, "authz", a) for a in o.authz_ids], + "finalize": u(request, "order", order_id, "finalize"), + } + if o.cert_id: + view["certificate"] = u(request, "cert", o.cert_id) + return view + + +# ---------- Order / Authz / Challenge GET (POST-as-GET) ---------- + +@app.post(ROOT + "/order/{order_id}") +async def order(order_id: str, request: Request) -> JSONResponse: + body = await _read_jws(request) + _verify_with_account(body) + if order_id not in _orders: + raise HTTPException(404, "order not found") + return JSONResponse(_order_view(request, order_id)) + + +@app.post(ROOT + "/authz/{authz_id}") +async def authz(authz_id: str, request: Request) -> JSONResponse: + body = await _read_jws(request) + _verify_with_account(body) + if authz_id not in _authzs: + raise HTTPException(404, "authz not found") + return JSONResponse(_authz_view(request, authz_id)) + + +def _authz_view(request: Request, authz_id: str) -> dict: + a = _authzs[authz_id] + return { + "status": a.status, + "identifier": {"type": a.identifier_type, "value": a.identifier_value}, + "challenges": [_challenge_view(request, c) for c in a.challenge_ids], + } + + +def _challenge_view(request: Request, chall_id: str) -> dict: + c = _challenges[chall_id] + return { + "type": c.type, + "url": u(request, "chall", c.id), + "status": c.status, + "token": c.token, + } + + +# ---------- registrar-api-01 challenge validation ---------- + +@app.post(ROOT + "/chall/{chall_id}") +async def challenge(chall_id: str, request: Request) -> JSONResponse: + body = await _read_jws(request) + account, _, payload = _verify_with_account(body) + if chall_id not in _challenges: + raise HTTPException(404, "challenge not found") + c = _challenges[chall_id] + a = _authzs[c.authz_id] + + # Empty payload "{}" signals readiness (RFC 8555 §7.5.1). + if payload and payload != b"{}": + raise HTTPException(400, "challenge POST payload must be empty object") + + # Compute expected key-authorization + expected = key_authorization(c.token, account.jwk) + + # Fetch the RP List entry for this WRP + async with httpx.AsyncClient(timeout=5.0) as client: + r = await client.get(f"{RP_LIST_URL}/rp-list/{a.identifier_value}") + if r.status_code != 200: + c.status = "invalid" + a.status = "invalid" + _challenges.save(c.id); _authzs.save(a.id) + raise HTTPException(403, f"WRP not in RP List: {a.identifier_value}") + entry = r.json() + if entry.get("status") != "active": + c.status = "invalid" + a.status = "invalid" + _challenges.save(c.id); _authzs.save(a.id) + raise HTTPException(403, "WRP not active") + + # acme-challenge may be a bare string (single-instance shorthand) or a + # structured object per CS-06 §7.8. We only support single-instance. + chal = entry.get("acme_challenge") + if isinstance(chal, dict): + chal = chal.get("default") + if chal != expected: + c.status = "invalid" + a.status = "invalid" + _challenges.save(c.id); _authzs.save(a.id) + raise HTTPException( + 403, + "RP List acme-challenge does not match expected key-authorization", + ) + + c.status = "valid" + a.status = "valid" + _challenges.save(c.id); _authzs.save(a.id) + + # Move order toward `ready` if all authz are valid + o = _orders[a.order_id] + if all(_authzs[az].status == "valid" for az in o.authz_ids): + o.status = "ready" + _orders.save(o.id) + + return JSONResponse(_challenge_view(request, chall_id)) + + +# ---------- finalize ---------- + +@app.post(ROOT + "/order/{order_id}/finalize") +async def finalize(order_id: str, request: Request) -> JSONResponse: + body = await _read_jws(request) + account, _, payload = _verify_with_account(body) + if order_id not in _orders: + raise HTTPException(404, "order not found") + o = _orders[order_id] + if o.account_id != account.id: + raise HTTPException(403, "order belongs to another account") + if o.status != "ready": + raise HTTPException(403, f"order not ready (status={o.status})") + + payload_json = __import__("json").loads(payload.decode("utf-8")) if payload else {} + csr_b64u = payload_json.get("csr") + if not csr_b64u: + raise HTTPException(400, "csr field required") + + import base64 + pad = "=" * (-len(csr_b64u) % 4) + csr_der = base64.urlsafe_b64decode(csr_b64u + pad) + csr = x509.load_der_x509_csr(csr_der) + + # Cross-check CSR Subject matches the RP List entry for the order's WRP + wrp_id = o.identifiers[0]["value"] + async with httpx.AsyncClient(timeout=5.0) as client: + r = await client.get(f"{RP_LIST_URL}/rp-list/{wrp_id}") + if r.status_code != 200: + raise HTTPException(403, "WRP not in RP List at finalize time") + entry = r.json() + + subject_attrs = {a.oid.dotted_string: a.value for a in csr.subject} + issues = [] + if subject_attrs.get("2.5.4.97") != entry["organization_identifier"]: + issues.append( + f"organizationIdentifier mismatch: csr={subject_attrs.get('2.5.4.97')!r} " + f"rp-list={entry['organization_identifier']!r}" + ) + if subject_attrs.get("2.5.4.10") != entry["legal_name"]: + issues.append( + f"O mismatch: csr={subject_attrs.get('2.5.4.10')!r} " + f"rp-list={entry['legal_name']!r}" + ) + if subject_attrs.get("2.5.4.6") != entry["country"]: + issues.append( + f"C mismatch: csr={subject_attrs.get('2.5.4.6')!r} " + f"rp-list={entry['country']!r}" + ) + if issues: + raise HTTPException( + 400, + "CSR Subject does not match RP List entry: " + "; ".join(issues), + ) + + o.status = "processing" + _orders.save(o.id) + + # Forward to Dogtag + csr_pem = csr.public_bytes(Encoding.PEM) + try: + cert_pem = await submit_csr(csr_pem) + except Exception as exc: + o.status = "invalid" + _orders.save(o.id) + raise HTTPException(500, f"CA issuance failed: {exc}") + + cert_id = uuid.uuid4().hex[:16] + _certs[cert_id] = cert_pem + o.cert_id = cert_id + o.status = "valid" + _orders.save(o.id) + return JSONResponse(_order_view(request, order_id)) + + +# ---------- cert download ---------- + +@app.post(ROOT + "/cert/{cert_id}") +async def cert(cert_id: str, request: Request) -> Response: + body = await _read_jws(request) + _verify_with_account(body) + if cert_id not in _certs: + raise HTTPException(404, "cert not found") + return PlainTextResponse( + _certs[cert_id], + media_type="application/pem-certificate-chain", + ) + + +@app.post(ROOT + "/revoke-cert") +async def revoke_cert(request: Request) -> Response: + raise HTTPException(501, "revocation not implemented in Phase 3 MVP") + + +@app.get("/tos") +def tos() -> PlainTextResponse: + return PlainTextResponse("WE BUILD interoperability testing only.") diff --git a/deployment/files/wrpac-acme/wrpac_acme/negative_tests.py b/deployment/files/wrpac-acme/wrpac_acme/negative_tests.py new file mode 100644 index 00000000..c2f69660 --- /dev/null +++ b/deployment/files/wrpac-acme/wrpac_acme/negative_tests.py @@ -0,0 +1,232 @@ +"""Negative tests for the CS-06 façade. + +Exercises CS-06 §7.2 MUST clauses by trying things the façade must reject. + +Run: + python -m wrpac_acme.negative_tests \ + --directory https://rpca.lab.cleverbase.io/acme-eudi-wrpac/directory \ + --rp-list https://rpca.lab.cleverbase.io +""" +from __future__ import annotations + +import argparse +import base64 +import json +import sys +from typing import Tuple + +import httpx +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID +from jwcrypto import jwk, jws + + +def b64u(b: bytes) -> str: + return base64.urlsafe_b64encode(b).rstrip(b"=").decode("ascii") + + +def sign(key: jwk.JWK, protected: dict, payload: bytes) -> dict: + tok = jws.JWS(payload) + tok.add_signature(key, alg=protected.get("alg", "RS256"), + protected=json.dumps(protected)) + return json.loads(tok.serialize()) + + +def pass_(name: str) -> None: + print(f" PASS {name}") + + +def fail(name: str, why: str) -> None: + print(f" FAIL {name} — {why}") + + +class Runner: + def __init__(self, directory_url: str, rp_list_url: str) -> None: + self.directory_url = directory_url + self.rp_list_url = rp_list_url.rstrip("/") + self.http = httpx.Client(timeout=15.0) + self.dir: dict = {} + self.fails = 0 + self.passes = 0 + + def record(self, ok: bool, name: str, why: str = "") -> None: + if ok: + self.passes += 1 + pass_(name) + else: + self.fails += 1 + fail(name, why) + + def setup(self) -> None: + self.dir = self.http.get(self.directory_url).json() + + def fresh_nonce(self) -> str: + return self.http.head(self.dir["newNonce"]).headers["Replay-Nonce"] + + def fresh_account(self) -> Tuple[jwk.JWK, str]: + """Successfully create a new account and return (key, account_url).""" + key = jwk.JWK.generate(kty="RSA", size=2048) + nonce = self.fresh_nonce() + eab_protected = b64u(json.dumps({"alg": "HS256", "kid": "eab-test", + "url": self.dir["newAccount"]}).encode()) + eab_payload = b64u(key.export_public().encode()) + eab_sig = b64u(b"STUB") + payload = { + "termsOfServiceAgreed": True, + "contact": ["mailto:neg@test"], + "externalAccountBinding": { + "protected": eab_protected, + "payload": eab_payload, + "signature": eab_sig, + }, + } + protected = {"alg": "RS256", "nonce": nonce, "url": self.dir["newAccount"], + "jwk": json.loads(key.export_public())} + r = self.http.post(self.dir["newAccount"], + json=sign(key, protected, + json.dumps(payload).encode()), + headers={"Content-Type": "application/jose+json"}) + return key, r.headers["Location"] + + def post(self, url: str, key: jwk.JWK, kid: str, + payload: dict | None) -> httpx.Response: + nonce = self.fresh_nonce() + protected = {"alg": "RS256", "nonce": nonce, "url": url, "kid": kid} + body = sign(key, protected, + b"" if payload is None else json.dumps(payload).encode()) + return self.http.post(url, json=body, + headers={"Content-Type": "application/jose+json"}) + + # ---- tests ---- + + def t_newaccount_without_eab(self) -> None: + name = "newAccount without externalAccountBinding rejected (§7.2 #2)" + key = jwk.JWK.generate(kty="RSA", size=2048) + nonce = self.fresh_nonce() + protected = {"alg": "RS256", "nonce": nonce, "url": self.dir["newAccount"], + "jwk": json.loads(key.export_public())} + payload = {"termsOfServiceAgreed": True, "contact": ["mailto:x@y"]} + r = self.http.post(self.dir["newAccount"], + json=sign(key, protected, + json.dumps(payload).encode()), + headers={"Content-Type": "application/jose+json"}) + self.record(r.status_code == 400, name, + f"expected 400, got {r.status_code}: {r.text[:200]}") + + def t_neworder_bad_identifier_type(self) -> None: + name = "newOrder rejects non-wrp-id identifier (§7.2 #3)" + key, acct = self.fresh_account() + r = self.post(self.dir["newOrder"], key, acct, + {"identifiers": [{"type": "dns", "value": "example.com"}]}) + self.record(r.status_code == 400, name, + f"expected 400, got {r.status_code}: {r.text[:200]}") + + def t_challenge_unknown_wrp(self) -> None: + name = "registrar-api-01 fails for wrp-id not in RP List (§7.2 #4,5)" + key, acct = self.fresh_account() + r = self.post(self.dir["newOrder"], key, acct, + {"identifiers": [{"type": "wrp-id", + "value": "NLKVK.UNKNOWN-9999"}]}) + if r.status_code not in (200, 201): + self.record(False, name, f"newOrder failed unexpectedly: {r.status_code}") + return + order = r.json() + authz = self.post(order["authorizations"][0], key, acct, None).json() + chall_url = next(c["url"] for c in authz["challenges"] + if c["type"] == "registrar-api-01") + # Skip placing key-auth — server will fetch from RP List and 404 + r = self.post(chall_url, key, acct, {}) + self.record(r.status_code == 403, name, + f"expected 403, got {r.status_code}: {r.text[:200]}") + + def t_challenge_wrong_keyauth(self) -> None: + name = "registrar-api-01 fails when RP List acme-challenge mismatches (§7.2 #4,5)" + key, acct = self.fresh_account() + r = self.post(self.dir["newOrder"], key, acct, + {"identifiers": [{"type": "wrp-id", + "value": "NLKVK.12345678"}]}) + order = r.json() + authz = self.post(order["authorizations"][0], key, acct, None).json() + chall = next(c for c in authz["challenges"] + if c["type"] == "registrar-api-01") + # Put a deliberately WRONG key-auth in the RP List + wrong = chall["token"] + ".DELIBERATELY-WRONG-THUMBPRINT" + self.http.put(f"{self.rp_list_url}/rp-list/NLKVK.12345678/acme-challenge", + json={"key_authorization": wrong}) + r = self.post(chall["url"], key, acct, {}) + self.record(r.status_code == 403, name, + f"expected 403, got {r.status_code}: {r.text[:200]}") + + def t_finalize_subject_mismatch(self) -> None: + name = "finalize rejects CSR whose Subject doesn't match RP List entry" + key, acct = self.fresh_account() + # newOrder + r = self.post(self.dir["newOrder"], key, acct, + {"identifiers": [{"type": "wrp-id", + "value": "NLKVK.12345678"}]}) + order = r.json() + order_url = r.headers["Location"] + # complete challenge with CORRECT key-auth + authz = self.post(order["authorizations"][0], key, acct, None).json() + chall = next(c for c in authz["challenges"] + if c["type"] == "registrar-api-01") + key_auth = f"{chall['token']}.{key.thumbprint()}" + self.http.put(f"{self.rp_list_url}/rp-list/NLKVK.12345678/acme-challenge", + json={"key_authorization": key_auth}) + self.post(chall["url"], key, acct, {}) + # Build CSR with WRONG O / organizationIdentifier + cert_key = rsa.generate_private_key(public_exponent=65537, key_size=3072) + wrong_subject = x509.Name([ + x509.NameAttribute(NameOID.COUNTRY_NAME, "DE"), + x509.NameAttribute(x509.ObjectIdentifier("2.5.4.97"), + "VAT-WRONG-IDENTIFIER"), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Wrong Org GmbH"), + x509.NameAttribute(NameOID.COMMON_NAME, "Wrong friendly name"), + ]) + csr = (x509.CertificateSigningRequestBuilder() + .subject_name(wrong_subject) + .sign(cert_key, hashes.SHA256())) + csr_der = csr.public_bytes(serialization.Encoding.DER) + r = self.post(order["finalize"], key, acct, {"csr": b64u(csr_der)}) + self.record(r.status_code == 400, name, + f"expected 400, got {r.status_code}: {r.text[:200]}") + + def t_revoke_unimplemented(self) -> None: + name = "revokeCert returns 501 (stub — known gap)" + key, acct = self.fresh_account() + r = self.post(self.dir["revokeCert"], key, acct, {"certificate": "x"}) + self.record(r.status_code == 501, name, + f"expected 501, got {r.status_code}: {r.text[:200]}") + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--directory", required=True) + ap.add_argument("--rp-list", required=True) + args = ap.parse_args() + r = Runner(args.directory, args.rp_list) + r.setup() + print("CS-06 negative-test battery") + print("=" * 60) + for t in ( + r.t_newaccount_without_eab, + r.t_neworder_bad_identifier_type, + r.t_challenge_unknown_wrp, + r.t_challenge_wrong_keyauth, + r.t_finalize_subject_mismatch, + r.t_revoke_unimplemented, + ): + try: + t() + except Exception as exc: + r.fails += 1 + fail(t.__name__, f"exception: {exc}") + print("=" * 60) + print(f" {r.passes} passed, {r.fails} failed") + return 0 if r.fails == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/deployment/files/wrpac-acme/wrpac_acme/rp_list.py b/deployment/files/wrpac-acme/wrpac_acme/rp_list.py new file mode 100644 index 00000000..4d13584f --- /dev/null +++ b/deployment/files/wrpac-acme/wrpac_acme/rp_list.py @@ -0,0 +1,129 @@ +"""Mock Registrar / RP List service per CS-06 §7.8. + +Stand-in for the national Registrar's API in the WE BUILD pilot. + +Endpoints: + GET /rp-list/{wrp_id} — return the RP entry + PUT /rp-list/{wrp_id} — admin: create / update entry + PUT /rp-list/{wrp_id}/acme-challenge — WRP places its key-authorization + DELETE /rp-list/{wrp_id} — admin + GET /rp-list — list all WRPs + +In production these would be queried per EC TS5 / TS6. +""" +from __future__ import annotations + +import os +from typing import Any, Dict, Optional, Union + +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel, Field + +from .storage import Store + +app = FastAPI(title="WE BUILD Mock RP List", version="0.1.0") + +DB_PATH = os.getenv("WRPAC_RP_LIST_DB_PATH", "/var/lib/wrpac-acme/rp_list.sqlite3") + + +class ACMEChallenge(BaseModel): + """CS-06 §7.8 allows both a single key-authorization string and a + multi-instance dict keyed by `instanceId`. We accept both.""" + + default: Optional[str] = None + instances: Dict[str, str] = Field(default_factory=dict) + + +class RPListEntry(BaseModel): + wrp_id: str + legal_name: str + organization_identifier: str + country: str = "NL" + status: str = "active" + acme_challenge: Union[str, ACMEChallenge, None] = None + + +def _entry_to_dict(e: RPListEntry) -> dict: + return e.model_dump(mode="json") + + +def _entry_from_dict(d: dict) -> RPListEntry: + return RPListEntry.model_validate(d) + + +_store: Store[RPListEntry] = Store(DB_PATH, "entries", _entry_to_dict, _entry_from_dict) + + +def _seed() -> None: + """Pre-populate one test WRP so the smoke client has something to enrol. + + Only seeds if the WRP isn't already in the persistent store — re-running + after a restart preserves the entry (including any acme-challenge values + a client has placed).""" + seed = os.getenv("WRPAC_SEED_WRP_ID", "NLKVK.12345678") + if seed in _store: + return + _store[seed] = RPListEntry( + wrp_id=seed, + legal_name=os.getenv("WRPAC_SEED_LEGAL_NAME", "ACME Test Organisation B.V."), + organization_identifier=os.getenv("WRPAC_SEED_ORG_ID", "VATNL-000000000B01"), + country=os.getenv("WRPAC_SEED_COUNTRY", "NL"), + status="active", + acme_challenge=None, + ) + + +_seed() + + +@app.get("/rp-list") +def list_all() -> Dict[str, Any]: + return {"items": [e.model_dump() for e in _store.values()]} + + +@app.get("/rp-list/{wrp_id}") +def get_entry(wrp_id: str) -> RPListEntry: + entry = _store.get(wrp_id) + if entry is None: + raise HTTPException(404, "WRP not found") + return entry + + +@app.put("/rp-list/{wrp_id}") +def put_entry(wrp_id: str, entry: RPListEntry) -> RPListEntry: + if entry.wrp_id != wrp_id: + raise HTTPException(400, "wrp_id in path/body mismatch") + _store[wrp_id] = entry + return entry + + +class ChallengePayload(BaseModel): + key_authorization: str + instance_id: Optional[str] = None + + +@app.put("/rp-list/{wrp_id}/acme-challenge") +def put_challenge(wrp_id: str, payload: ChallengePayload) -> Dict[str, Any]: + entry = _store.get(wrp_id) + if entry is None: + raise HTTPException(404, "WRP not found") + if entry.status != "active": + raise HTTPException(403, "WRP not active") + if payload.instance_id: + # Multi-instance form + if not isinstance(entry.acme_challenge, ACMEChallenge): + entry.acme_challenge = ACMEChallenge() + entry.acme_challenge.instances[payload.instance_id] = payload.key_authorization + else: + # Single-instance shorthand (CS-06 §7.8) + entry.acme_challenge = payload.key_authorization + _store[wrp_id] = entry + return {"wrp_id": wrp_id, "acme_challenge": entry.acme_challenge} + + +@app.delete("/rp-list/{wrp_id}") +def delete_entry(wrp_id: str) -> Dict[str, str]: + if wrp_id not in _store: + raise HTTPException(404, "WRP not found") + del _store[wrp_id] + return {"deleted": wrp_id} diff --git a/deployment/files/wrpac-acme/wrpac_acme/storage.py b/deployment/files/wrpac-acme/wrpac_acme/storage.py new file mode 100644 index 00000000..3870ae48 --- /dev/null +++ b/deployment/files/wrpac-acme/wrpac_acme/storage.py @@ -0,0 +1,79 @@ +"""SQLite-backed dict-shaped key-value store. + +All rows are loaded into memory on startup and every mutation is mirrored to +disk in WAL mode. This is the minimum needed to let the Phase 3 façade and the +mock RP List survive a process restart — explicitly not a production database +layer. + +Callers may mutate stored values *in place* (e.g. ``acct.status = "valid"``); +they must call ``store.save(key)`` afterwards so the change is persisted. +""" +from __future__ import annotations + +import json +import os +import sqlite3 +from typing import Any, Callable, Generic, Iterator, Optional, TypeVar + +V = TypeVar("V") + + +class Store(Generic[V]): + def __init__( + self, + db_path: str, + table: str, + to_dict: Callable[[V], dict], + from_dict: Callable[[dict], V], + ) -> None: + self._table = table + self._to_dict = to_dict + self._from_dict = from_dict + parent = os.path.dirname(db_path) or "." + os.makedirs(parent, exist_ok=True) + # isolation_level=None → autocommit; WAL → safe concurrent readers + self._db = sqlite3.connect(db_path, check_same_thread=False, + isolation_level=None) + self._db.execute("PRAGMA journal_mode=WAL") + self._db.execute( + f"CREATE TABLE IF NOT EXISTS {table} " + "(k TEXT PRIMARY KEY, v TEXT NOT NULL)" + ) + self._mem: dict[str, V] = {} + for k, v in self._db.execute(f"SELECT k, v FROM {table}"): + self._mem[k] = from_dict(json.loads(v)) + + def __setitem__(self, key: str, value: V) -> None: + self._mem[key] = value + self._db.execute( + f"INSERT OR REPLACE INTO {self._table} (k, v) VALUES (?, ?)", + (key, json.dumps(self._to_dict(value))), + ) + + def save(self, key: str) -> None: + """Re-persist a key after in-place mutation of its value.""" + if key not in self._mem: + raise KeyError(key) + self.__setitem__(key, self._mem[key]) + + def __getitem__(self, key: str) -> V: + return self._mem[key] + + def __delitem__(self, key: str) -> None: + del self._mem[key] + self._db.execute(f"DELETE FROM {self._table} WHERE k = ?", (key,)) + + def __contains__(self, key: object) -> bool: + return key in self._mem + + def __iter__(self) -> Iterator[str]: + return iter(self._mem) + + def __len__(self) -> int: + return len(self._mem) + + def get(self, key: str, default: Any = None) -> Optional[V]: + return self._mem.get(key, default) + + def values(self) -> Iterator[V]: + return iter(self._mem.values()) diff --git a/deployment/files/wrpacCert.cfg.j2 b/deployment/files/wrpacCert.cfg.j2 new file mode 100644 index 00000000..e665900f --- /dev/null +++ b/deployment/files/wrpacCert.cfg.j2 @@ -0,0 +1,141 @@ +profileId={{ wrpac_profile_id }} +classId=caEnrollImpl +desc=WE BUILD Wallet-Relying Party Access Certificate (CS-06) +visible=true +enable=true +enableBy=admin +auth.instance_id=AgentCertAuth +authz.acl=group=Certificate Manager Agents +name=WE BUILD WRPAC Enrollment +input.list=i1,i2 +input.i1.class_id=certReqInputImpl +input.i2.class_id=submitterInfoInputImpl +output.list=o1 +output.o1.class_id=certOutputImpl +policyset.list=wrpacSet +policyset.wrpacSet.list=1,2,3,4,5,6,7,8,9,10,11,12 + +# 1. Subject DN — taken from the CSR (allows organizationIdentifier, O, CN, C) +# CS-06 §7.4 #3,#4,#5,#9,#10 require Subject DN attributes per ETSI EN 319 412-1 +# and TS 119 475 clause 5. Pattern enforces that organizationIdentifier is present. +policyset.wrpacSet.1.constraint.class_id=subjectNameConstraintImpl +policyset.wrpacSet.1.constraint.name=Subject Name Constraint +policyset.wrpacSet.1.constraint.params.pattern=.*2\.5\.4\.97=.+ +policyset.wrpacSet.1.constraint.params.accept=true +policyset.wrpacSet.1.default.class_id=userSubjectNameDefaultImpl +policyset.wrpacSet.1.default.name=User Supplied Subject Name Default + +# 2. Validity — 1 year (CS-06 §7.4 #13) +policyset.wrpacSet.2.constraint.class_id=validityConstraintImpl +policyset.wrpacSet.2.constraint.name=Validity Constraint +policyset.wrpacSet.2.constraint.params.range={{ wrpac_validity_days }} +policyset.wrpacSet.2.constraint.params.notBeforeCheck=false +policyset.wrpacSet.2.constraint.params.notAfterCheck=false +policyset.wrpacSet.2.default.class_id=validityDefaultImpl +policyset.wrpacSet.2.default.name=Validity Default +policyset.wrpacSet.2.default.params.range={{ wrpac_validity_days }} +policyset.wrpacSet.2.default.params.startTime=0 + +# 3. Key Usage — digitalSignature + keyEncipherment, critical +policyset.wrpacSet.3.constraint.class_id=keyUsageExtConstraintImpl +policyset.wrpacSet.3.constraint.name=Key Usage Extension Constraint +policyset.wrpacSet.3.constraint.params.keyUsageCritical=true +policyset.wrpacSet.3.constraint.params.keyUsageDigitalSignature=true +policyset.wrpacSet.3.constraint.params.keyUsageNonRepudiation=false +policyset.wrpacSet.3.constraint.params.keyUsageDataEncipherment=false +policyset.wrpacSet.3.constraint.params.keyUsageKeyEncipherment=true +policyset.wrpacSet.3.constraint.params.keyUsageKeyAgreement=false +policyset.wrpacSet.3.constraint.params.keyUsageKeyCertSign=false +policyset.wrpacSet.3.constraint.params.keyUsageCrlSign=false +policyset.wrpacSet.3.constraint.params.keyUsageEncipherOnly=false +policyset.wrpacSet.3.constraint.params.keyUsageDecipherOnly=false +policyset.wrpacSet.3.default.class_id=keyUsageExtDefaultImpl +policyset.wrpacSet.3.default.name=Key Usage Default +policyset.wrpacSet.3.default.params.keyUsageCritical=true +policyset.wrpacSet.3.default.params.keyUsageDigitalSignature=true +policyset.wrpacSet.3.default.params.keyUsageNonRepudiation=false +policyset.wrpacSet.3.default.params.keyUsageDataEncipherment=false +policyset.wrpacSet.3.default.params.keyUsageKeyEncipherment=true +policyset.wrpacSet.3.default.params.keyUsageKeyAgreement=false +policyset.wrpacSet.3.default.params.keyUsageKeyCertSign=false +policyset.wrpacSet.3.default.params.keyUsageCrlSign=false +policyset.wrpacSet.3.default.params.keyUsageEncipherOnly=false +policyset.wrpacSet.3.default.params.keyUsageDecipherOnly=false + +# 4. Extended Key Usage — TLS clientAuth (RP authenticates to wallet via mTLS) +policyset.wrpacSet.4.constraint.class_id=noConstraintImpl +policyset.wrpacSet.4.constraint.name=No Constraint +policyset.wrpacSet.4.default.class_id=extendedKeyUsageExtDefaultImpl +policyset.wrpacSet.4.default.name=Extended Key Usage Extension Default +policyset.wrpacSet.4.default.params.exKeyUsageCritical=false +policyset.wrpacSet.4.default.params.exKeyUsageOIDs=1.3.6.1.5.5.7.3.2,1.3.6.1.5.5.7.3.1 + +# 5. Basic Constraints — CA=false, critical +policyset.wrpacSet.5.constraint.class_id=basicConstraintsExtConstraintImpl +policyset.wrpacSet.5.constraint.name=Basic Constraint Extension Constraint +policyset.wrpacSet.5.constraint.params.basicConstraintsCritical=true +policyset.wrpacSet.5.constraint.params.basicConstraintsIsCA=false +policyset.wrpacSet.5.constraint.params.basicConstraintsMinPathLen=-1 +policyset.wrpacSet.5.constraint.params.basicConstraintsMaxPathLen=-1 +policyset.wrpacSet.5.default.class_id=basicConstraintsExtDefaultImpl +policyset.wrpacSet.5.default.name=Basic Constraints Extension Default +policyset.wrpacSet.5.default.params.basicConstraintsCritical=true +policyset.wrpacSet.5.default.params.basicConstraintsIsCA=false +policyset.wrpacSet.5.default.params.basicConstraintsPathLen=-1 + +# 6. Authority Key Identifier +policyset.wrpacSet.6.constraint.class_id=noConstraintImpl +policyset.wrpacSet.6.constraint.name=No Constraint +policyset.wrpacSet.6.default.class_id=authorityKeyIdentifierExtDefaultImpl +policyset.wrpacSet.6.default.name=Authority Key Identifier Default + +# 7. AIA — CS-06 §7.4 #7 (certification path information URI) +policyset.wrpacSet.7.constraint.class_id=noConstraintImpl +policyset.wrpacSet.7.constraint.name=No Constraint +policyset.wrpacSet.7.default.class_id=authInfoAccessExtDefaultImpl +policyset.wrpacSet.7.default.name=AIA Extension Default +policyset.wrpacSet.7.default.params.authInfoAccessADEnable_0=true +policyset.wrpacSet.7.default.params.authInfoAccessADLocationType_0=URIName +policyset.wrpacSet.7.default.params.authInfoAccessADLocation_0=http://{{ ca_fqdn }}:8080/ca/ocsp +policyset.wrpacSet.7.default.params.authInfoAccessADMethod_0=1.3.6.1.5.5.7.48.1 +policyset.wrpacSet.7.default.params.authInfoAccessCritical=false +policyset.wrpacSet.7.default.params.authInfoAccessNumADs=1 + +# 8. SAN — pass through user-supplied SAN extension (CS-06 §7.4 #8) +policyset.wrpacSet.8.constraint.class_id=noConstraintImpl +policyset.wrpacSet.8.constraint.name=No Constraint +policyset.wrpacSet.8.default.class_id=userExtensionDefaultImpl +policyset.wrpacSet.8.default.name=User supplied SAN extension +policyset.wrpacSet.8.default.params.userExtOID=2.5.29.17 + +# 9. Signing algorithm — SHA-256 or stronger, RSA or ECDSA (CS-06 §7.4 #12) +policyset.wrpacSet.9.constraint.class_id=signingAlgConstraintImpl +policyset.wrpacSet.9.constraint.name=Signing Algorithm Constraint +policyset.wrpacSet.9.constraint.params.signingAlgsAllowed=SHA256withRSA,SHA384withRSA,SHA512withRSA,SHA256withEC,SHA384withEC,SHA512withEC +policyset.wrpacSet.9.default.class_id=signingAlgDefaultImpl +policyset.wrpacSet.9.default.name=Signing Algorithm Default +policyset.wrpacSet.9.default.params.signingAlg=- + +# 10. Subject Key Identifier +policyset.wrpacSet.10.constraint.class_id=noConstraintImpl +policyset.wrpacSet.10.constraint.name=No Constraint +policyset.wrpacSet.10.default.class_id=subjectKeyIdentifierExtDefaultImpl +policyset.wrpacSet.10.default.name=Subject Key Identifier Default +policyset.wrpacSet.10.default.params.critical=false + +# 11. Key Constraint — RSA 3072/4096 or ECDSA P-256/P-384 (CS-06 §7.4 #12) +policyset.wrpacSet.11.constraint.class_id=keyConstraintImpl +policyset.wrpacSet.11.constraint.name=Key Constraint +policyset.wrpacSet.11.constraint.params.keyType=- +policyset.wrpacSet.11.constraint.params.keyParameters=3072,4096,nistp256,nistp384 +policyset.wrpacSet.11.default.class_id=userKeyDefaultImpl +policyset.wrpacSet.11.default.name=Key Default + +# 12. Certificate Policies — WRPAC policy OID (CS-06 §7.4 #2,#6) +policyset.wrpacSet.12.constraint.class_id=noConstraintImpl +policyset.wrpacSet.12.constraint.name=No Constraint +policyset.wrpacSet.12.default.class_id=certificatePoliciesExtDefaultImpl +policyset.wrpacSet.12.default.name=Certificate Policies Extension Default +policyset.wrpacSet.12.default.params.PoliciesExt.num=1 +policyset.wrpacSet.12.default.params.PoliciesExt.certPolicy0.enable=true +policyset.wrpacSet.12.default.params.PoliciesExt.certPolicy0.policyId={{ wrpac_policy_oid_legal }} diff --git a/deployment/group_vars/all.yml b/deployment/group_vars/all.yml new file mode 100644 index 00000000..702206cd --- /dev/null +++ b/deployment/group_vars/all.yml @@ -0,0 +1,53 @@ +--- +ca_fqdn: mw-vm-ca01.lab + +ds_instance_name: pki +ds_suffix: dc=ca,dc=lab +ds_base_dn_ca: ou=ca,dc=ca,dc=lab + +pki_instance_name: pki-tomcat + +# Test-only passwords. CS-06 §1 marks this spec as interoperability-testing only, +# not production. Override via ansible-vault for any non-test environment. +ds_dm_password: Secret.123 +pki_admin_password: Secret.123 +pki_ds_password: Secret.123 +pki_security_domain_password: Secret.123 +pki_admin_uid: caadmin +pki_admin_email: "caadmin@{{ ca_fqdn }}" + +acme_realm_type: in-memory + +# Dogtag ACME database backend. "in-memory" wipes state on pki-tomcatd restart +# (fine for throwaway smoke tests); "ds" persists into the 389-DS instance. +acme_database_backend: ds + +# 389-DS subtree where the ACME data lives when acme_database_backend == ds. +acme_db_basedn: "ou=acme,{{ ds_suffix }}" + +# WRPAC profile (CS-06 §7.4). +wrpac_profile_id: wrpacCert +wrpac_validity_days: 365 + +# WRPAC policy OIDs from ETSI TS 119 411-8 v1.1.1. +# Values below are PLACEHOLDERS in the ETSI test arc — replace with the +# OIDs assigned by the published TS 119 411-8 document for production use. +# CS-06 §7.4 #2 references NCP-l-eudiwrp (legal person) and NCP-n-eudiwrp +# (natural person). +wrpac_policy_oid_legal: "0.4.0.194112.1.99.1" +wrpac_policy_oid_natural: "0.4.0.194112.1.99.2" + +# Test WRP identity used by the direct-enrolment smoke test. +wrpac_test_organisation_name: "ACME Test Organisation B.V." +wrpac_test_organisation_identifier: "VATNL-000000000B01" +wrpac_test_country: "NL" +wrpac_test_friendly_name: "ACME RP Smoke Test" +wrpac_test_san_dns: "rp.test.lab" + +# CS-06 wrp-id identifier value used in Phase 3 (matches a seeded RP List entry). +wrpac_test_wrp_id: "NLKVK.12345678" + +# Phase 3 façade / RP List service config. +wrpac_facade_root: /opt/wrpac-acme +wrpac_facade_port: 9080 +wrpac_rp_list_port: 9000 diff --git a/deployment/inventory.yml b/deployment/inventory.yml new file mode 100644 index 00000000..afdd7d37 --- /dev/null +++ b/deployment/inventory.yml @@ -0,0 +1,6 @@ +all: + hosts: + mw-vm-ca01.lab: + ansible_user: cbadmin + ansible_become: true + ansible_become_method: sudo diff --git a/deployment/nginx/ca-public.conf b/deployment/nginx/ca-public.conf new file mode 100644 index 00000000..c45fabfc --- /dev/null +++ b/deployment/nginx/ca-public.conf @@ -0,0 +1,81 @@ +# WE BUILD CS-06 CA — public-facing HTTP reverse proxy. +# +# Drop this on your nginx host (e.g. /etc/nginx/sites-available/ca-public.conf), +# replace `ca.example.com` with your real hostname, symlink into sites-enabled, +# `nginx -t && systemctl reload nginx`, then run certbot to upgrade to HTTPS. +# +# After certbot has flipped this to listen on 443, do the post-cert steps +# documented in deployment/README.md ("Exposing the CA externally") so that +# the ACME directory documents advertise your public https:// URLs instead +# of mw-vm-ca01.lab:8443/9080. + +server { + listen 80; + listen [::]:80; + server_name ca.example.com; # <-- replace + + # ---------- Stock Dogtag ACME (RFC 8555) ---------- + # Backend is HTTPS with Dogtag's self-signed cert, so we disable + # upstream verification. If you'd rather verify, drop the Dogtag root + # cert on the nginx host and switch this to: + # proxy_ssl_verify on; + # proxy_ssl_trusted_certificate /etc/ssl/dogtag-ca-root.pem; + location /acme/ { + proxy_pass https://mw-vm-ca01.lab:8443; + proxy_ssl_verify off; + proxy_ssl_server_name on; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + } + + # ---------- CS-06 ACME façade ---------- + location /acme-eudi-wrpac/ { + proxy_pass http://mw-vm-ca01.lab:9080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + } + + # /tos is linked from the façade's ACME directory metadata. + location = /tos { + proxy_pass http://mw-vm-ca01.lab:9080; + proxy_set_header Host $host; + } + + # ---------- Mock Registrar / RP List ---------- + # The PUT /rp-list/{wrp_id}/acme-challenge endpoint is what an ACME + # client uses to place its key-authorization, so it must be reachable. + # All RP List endpoints are currently UNAUTHENTICATED — restrict by IP + # (e.g. allow only your test-bed network) before exposing publicly. + location /rp-list { + # allow 198.51.100.0/24; # <-- your test-bed CIDR + # deny all; + proxy_pass http://mw-vm-ca01.lab:9000; + proxy_http_version 1.1; + proxy_set_header Host $host; + } + + # ---------- Optional: Dogtag EE pages + OCSP (HTTP) ---------- + # Useful if external clients ever need to browse the profile-select pages + # or hit OCSP. Comment out if not needed. + location ^~ /ca/ee/ { + proxy_pass http://mw-vm-ca01.lab:8080; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + } + location = /ca/ocsp { + proxy_pass http://mw-vm-ca01.lab:8080; + proxy_set_header Host $host; + } + + # ---------- Hard block credentialled surfaces ---------- + # These accept admin / agent credentials and MUST NOT be exposed publicly. + location ~ ^/ca/(admin|services|rest|agent)(/|$) { + return 403; + } +} diff --git a/deployment/playbooks/00-prereqs.yml b/deployment/playbooks/00-prereqs.yml new file mode 100644 index 00000000..77e5827d --- /dev/null +++ b/deployment/playbooks/00-prereqs.yml @@ -0,0 +1,74 @@ +--- +- name: Prereqs for Dogtag PKI CA on AlmaLinux 9 + hosts: all + gather_facts: true + tasks: + + - name: Verify expected OS family + ansible.builtin.assert: + that: + - ansible_facts['os_family'] == 'RedHat' + - ansible_facts['distribution_major_version'] | int == 9 + fail_msg: >- + This playbook targets RHEL 9 derivatives (AlmaLinux/Rocky/RHEL 9.x). + Found {{ ansible_facts['distribution'] }} {{ ansible_facts['distribution_version'] }}. + + - name: Set system hostname to {{ ca_fqdn }} + ansible.builtin.hostname: + name: "{{ ca_fqdn }}" + use: systemd + + - name: Pin {{ ca_fqdn }} in /etc/hosts + ansible.builtin.lineinfile: + path: /etc/hosts + regexp: "^[0-9.]+\\s+{{ ca_fqdn | regex_escape }}(\\s|$)" + line: "{{ ansible_facts['default_ipv4']['address'] }} {{ ca_fqdn }} {{ ca_fqdn.split('.')[0] }}" + state: present + + - name: Install EPEL + ansible.builtin.dnf: + name: epel-release + state: present + + - name: Check whether CRB repo is enabled + ansible.builtin.command: dnf -q repolist --enabled + register: dnf_enabled_repos + changed_when: false + + - name: Enable CRB repo (CodeReady Builder) — required for some PKI deps + ansible.builtin.command: dnf config-manager --set-enabled crb + when: "'crb' not in dnf_enabled_repos.stdout" + + - name: Install baseline packages (firewalld, chrony, tools) + ansible.builtin.dnf: + name: + - firewalld + - chrony + - policycoreutils-python-utils + - bind-utils + - tar + - which + state: present + + - name: Enable and start chronyd + ansible.builtin.systemd: + name: chronyd + enabled: true + state: started + + - name: Enable and start firewalld + ansible.builtin.systemd: + name: firewalld + enabled: true + state: started + + - name: Open Dogtag Tomcat ports in firewalld + ansible.posix.firewalld: + port: "{{ item }}" + permanent: true + immediate: true + state: enabled + loop: + - 8080/tcp # PKI HTTP (admin redirects to 8443) + - 8443/tcp # PKI HTTPS (CA + ACME endpoint) + - 389/tcp # 389-DS LDAP (localhost-bound by default; opened for ops) diff --git a/deployment/playbooks/10-dogtag-ca.yml b/deployment/playbooks/10-dogtag-ca.yml new file mode 100644 index 00000000..32c119e0 --- /dev/null +++ b/deployment/playbooks/10-dogtag-ca.yml @@ -0,0 +1,108 @@ +--- +- name: Provision 389-DS instance and Dogtag CA subsystem + hosts: all + gather_facts: true + vars: + ds_inf_path: /root/ds-instance.inf + ca_cfg_path: /root/ca.cfg + ds_instance_marker: "/etc/dirsrv/slapd-{{ ds_instance_name }}/dse.ldif" + ca_install_marker: "/var/lib/pki/{{ pki_instance_name }}/conf/ca/CS.cfg" + + tasks: + + - name: Install 389-DS base package + ansible.builtin.dnf: + name: 389-ds-base + state: present + + - name: Render 389-DS instance config (inf file) + ansible.builtin.copy: + dest: "{{ ds_inf_path }}" + owner: root + group: root + mode: "0600" + content: | + [general] + config_version = 2 + + [slapd] + instance_name = {{ ds_instance_name }} + root_password = {{ ds_dm_password }} + + [backend-userroot] + sample_entries = no + suffix = {{ ds_suffix }} + + - name: Check whether the 389-DS instance already exists + ansible.builtin.stat: + path: "{{ ds_instance_marker }}" + register: ds_marker + + - name: Create 389-DS instance via dscreate + ansible.builtin.command: "dscreate from-file {{ ds_inf_path }}" + when: not ds_marker.stat.exists + register: dscreate_result + changed_when: dscreate_result.rc == 0 + + - name: Ensure 389-DS instance is running + ansible.builtin.systemd: + name: "dirsrv@{{ ds_instance_name }}" + enabled: true + state: started + + - name: Install Dogtag CA packages + ansible.builtin.dnf: + name: + - pki-ca + - pki-server + - pki-tools + state: present + + - name: Render pkispawn CA config + ansible.builtin.copy: + dest: "{{ ca_cfg_path }}" + owner: root + group: root + mode: "0600" + content: | + [DEFAULT] + pki_admin_password={{ pki_admin_password }} + pki_client_database_password={{ pki_admin_password }} + pki_client_pkcs12_password={{ pki_admin_password }} + pki_server_database_password={{ pki_admin_password }} + pki_security_domain_password={{ pki_security_domain_password }} + pki_hostname={{ ca_fqdn }} + pki_ds_url=ldap://{{ ca_fqdn }}:389 + pki_ds_bind_dn=cn=Directory Manager + pki_ds_password={{ pki_ds_password }} + pki_ds_database=ca + pki_ds_base_dn={{ ds_base_dn_ca }} + pki_admin_email={{ pki_admin_email }} + pki_admin_uid={{ pki_admin_uid }} + pki_admin_name={{ pki_admin_uid }} + pki_admin_nickname={{ pki_admin_uid }} + + [CA] + + - name: Check whether the CA subsystem is already installed + ansible.builtin.stat: + path: "{{ ca_install_marker }}" + register: ca_marker + + - name: Run pkispawn for CA subsystem + ansible.builtin.command: "pkispawn -f {{ ca_cfg_path }} -s CA" + when: not ca_marker.stat.exists + register: pkispawn_ca + changed_when: pkispawn_ca.rc == 0 + + - name: Ensure pki-tomcatd is enabled and running + ansible.builtin.systemd: + name: "pki-tomcatd@{{ pki_instance_name }}" + enabled: true + state: started + + - name: Wait for CA endpoint on 8443 + ansible.builtin.wait_for: + host: 127.0.0.1 + port: 8443 + timeout: 60 diff --git a/deployment/playbooks/20-dogtag-acme.yml b/deployment/playbooks/20-dogtag-acme.yml new file mode 100644 index 00000000..50a7b50f --- /dev/null +++ b/deployment/playbooks/20-dogtag-acme.yml @@ -0,0 +1,125 @@ +--- +- name: Deploy Dogtag ACME subsystem + hosts: all + gather_facts: false + vars: + acme_conf_dir: "/var/lib/pki/{{ pki_instance_name }}/conf/acme" + acme_deploy_marker: "/var/lib/pki/{{ pki_instance_name }}/conf/Catalina/localhost/acme.xml" + + tasks: + + - name: Install pki-acme package + ansible.builtin.dnf: + name: pki-acme + state: present + + - name: Check whether acme subsystem dir exists + ansible.builtin.stat: + path: "{{ acme_conf_dir }}" + register: acme_conf_dir_stat + + - name: Create ACME subsystem (pki-server acme-create) + ansible.builtin.command: "pki-server acme-create" + when: not acme_conf_dir_stat.stat.exists + changed_when: true + + - name: Configure ACME database (in-memory) + ansible.builtin.copy: + dest: "{{ acme_conf_dir }}/database.conf" + owner: pkiuser + group: pkiuser + mode: "0660" + content: | + class=org.dogtagpki.acme.database.InMemoryDatabase + notify: Restart pki-tomcatd + + - name: Configure ACME issuer (PKIIssuer → local Dogtag CA) + ansible.builtin.copy: + dest: "{{ acme_conf_dir }}/issuer.conf" + owner: pkiuser + group: pkiuser + mode: "0660" + content: | + class=org.dogtagpki.acme.issuer.PKIIssuer + url=https://{{ ca_fqdn }}:8443 + profile=acmeServerCert + username={{ pki_admin_uid }} + password={{ pki_admin_password }} + notify: Restart pki-tomcatd + + - name: Configure ACME realm (in-memory) + ansible.builtin.copy: + dest: "{{ acme_conf_dir }}/realm.conf" + owner: pkiuser + group: pkiuser + mode: "0660" + content: | + class=org.dogtagpki.acme.realm.InMemoryRealm + username=admin + password={{ pki_admin_password }} + notify: Restart pki-tomcatd + + - name: Configure ACME engine (enable, retention, etc.) + ansible.builtin.copy: + dest: "{{ acme_conf_dir }}/engine.conf" + owner: pkiuser + group: pkiuser + mode: "0660" + content: | + enabled=true + policy.wildcard=false + policy.retention.nonces.length=30 + policy.retention.nonces.unit=MINUTES + policy.retention.pendingAuthorizations.length=30 + policy.retention.pendingAuthorizations.unit=MINUTES + policy.retention.invalidAuthorizations.length=30 + policy.retention.invalidAuthorizations.unit=MINUTES + policy.retention.validAuthorizations.length=30 + policy.retention.validAuthorizations.unit=MINUTES + policy.retention.pendingOrders.length=30 + policy.retention.pendingOrders.unit=MINUTES + policy.retention.invalidOrders.length=30 + policy.retention.invalidOrders.unit=MINUTES + policy.retention.readyOrders.length=30 + policy.retention.readyOrders.unit=MINUTES + policy.retention.processingOrders.length=30 + policy.retention.processingOrders.unit=MINUTES + policy.retention.validOrders.length=30 + policy.retention.validOrders.unit=MINUTES + policy.retention.certificates.length=30 + policy.retention.certificates.unit=DAYS + notify: Restart pki-tomcatd + + - name: Check whether ACME webapp is already deployed + ansible.builtin.stat: + path: "{{ acme_deploy_marker }}" + register: acme_deploy_stat + + - name: Deploy ACME webapp (pki-server acme-deploy) + ansible.builtin.command: "pki-server acme-deploy" + when: not acme_deploy_stat.stat.exists + changed_when: true + notify: Restart pki-tomcatd + + - name: Flush handlers (restart pki-tomcatd if needed) + ansible.builtin.meta: flush_handlers + + - name: Wait for ACME directory endpoint + ansible.builtin.uri: + url: "https://{{ ca_fqdn }}:8443/acme/directory" + validate_certs: false + return_content: true + register: acme_directory + retries: 12 + delay: 5 + until: acme_directory.status == 200 + + - name: Show ACME directory response + ansible.builtin.debug: + var: acme_directory.json + + handlers: + - name: Restart pki-tomcatd + ansible.builtin.systemd: + name: "pki-tomcatd@{{ pki_instance_name }}" + state: restarted diff --git a/deployment/playbooks/21-acme-persistence.yml b/deployment/playbooks/21-acme-persistence.yml new file mode 100644 index 00000000..53c2f792 --- /dev/null +++ b/deployment/playbooks/21-acme-persistence.yml @@ -0,0 +1,177 @@ +--- +# Switch the Dogtag ACME subsystem from the in-memory backend to the 389-DS +# backend so that accounts, orders, authz, challenges, and certificates +# survive pki-tomcatd restarts. +# +# Safe to run on a deployment that already has the in-memory backend running. +# +# What this playbook does: +# 1. Ensure DS schema for ACME attribute / object types is loaded +# 2. Create the ACME subtree (ou=acme,...) and child OUs (accounts, orders, ...) +# 3. Apply DS indexes the ACME engine queries on +# 4. Rewrite /var/lib/pki/pki-tomcat/conf/acme/database.conf to use DSDatabase +# 5. Restart pki-tomcatd and wait for /acme/directory to respond + +- name: Switch Dogtag ACME persistence to 389-DS + hosts: all + gather_facts: false + vars: + acme_db_url: "ldap://{{ ca_fqdn }}:389" + acme_db_bind_dn: "cn=Directory Manager" + acme_db_bind_password: "{{ ds_dm_password }}" + schema_src: /usr/share/pki/acme/database/ds/schema.ldif + index_src: /usr/share/pki/acme/database/ds/index.ldif + indextask_src: /usr/share/pki/acme/database/ds/indextask.ldif + + tasks: + + - name: Sanity-check we want the ds backend + ansible.builtin.assert: + that: acme_database_backend == "ds" + fail_msg: >- + acme_database_backend is {{ acme_database_backend }}. + Set it to "ds" in group_vars/all.yml before running this playbook. + + - name: Load ACME schema into 389-DS (continue-on-error; tolerate "already exists") + ansible.builtin.command: + argv: + - ldapmodify + - -c + - -H + - "{{ acme_db_url }}" + - -D + - "{{ acme_db_bind_dn }}" + - -w + - "{{ acme_db_bind_password }}" + - -f + - "{{ schema_src }}" + register: schema_apply + changed_when: "'modifying entry' in schema_apply.stdout" + failed_when: >- + schema_apply.rc != 0 + and 'Already exists' not in (schema_apply.stderr | default('')) + and 'Type or value exists' not in (schema_apply.stderr | default('')) + + - name: Write per-deployment create.ldif (root suffix + ACME subtree + child OUs) + ansible.builtin.copy: + dest: /root/acme-create.ldif + owner: root + group: root + mode: "0600" + content: | + dn: {{ ds_suffix }} + objectClass: top + objectClass: domain + dc: {{ ds_suffix.split(',')[0].split('=')[1] }} + + dn: {{ acme_db_basedn }} + objectClass: organizationalUnit + objectClass: extensibleObject + ou: acme + + dn: ou=config,{{ acme_db_basedn }} + objectClass: organizationalUnit + objectClass: extensibleObject + ou: config + + dn: ou=nonces,{{ acme_db_basedn }} + objectClass: organizationalUnit + ou: nonces + + dn: ou=accounts,{{ acme_db_basedn }} + objectClass: organizationalUnit + ou: accounts + + dn: ou=orders,{{ acme_db_basedn }} + objectClass: organizationalUnit + ou: orders + + dn: ou=authorizations,{{ acme_db_basedn }} + objectClass: organizationalUnit + ou: authorizations + + dn: ou=challenges,{{ acme_db_basedn }} + objectClass: organizationalUnit + ou: challenges + + dn: ou=certificates,{{ acme_db_basedn }} + objectClass: organizationalUnit + ou: certificates + + - name: Create root suffix + ACME subtree (tolerate already-exists) + ansible.builtin.command: + argv: + - ldapadd + - -c + - -H + - "{{ acme_db_url }}" + - -D + - "{{ acme_db_bind_dn }}" + - -w + - "{{ acme_db_bind_password }}" + - -f + - /root/acme-create.ldif + register: subtree_create + changed_when: "'adding new entry' in subtree_create.stdout" + # ldapadd -c returns rc=68 if every entry already exists, rc=0 if all new. + # Tolerate "Already exists" lines but fail on anything else. + failed_when: >- + subtree_create.rc != 0 + and ('No such object' in (subtree_create.stderr | default('')) + or 'Invalid' in (subtree_create.stderr | default('')) + or ('Already exists' not in (subtree_create.stderr | default('')) + and 'Already exists' not in (subtree_create.stdout | default('')))) + + - name: Apply ACME indexes + ansible.builtin.command: > + ldapadd -c -H {{ acme_db_url }} + -D "{{ acme_db_bind_dn }}" -w "{{ acme_db_bind_password }}" + -f {{ index_src }} + register: index_apply + failed_when: index_apply.rc != 0 and 'Already exists' not in (index_apply.stderr | default('') + index_apply.stdout | default('')) + changed_when: "'adding new entry' in (index_apply.stdout | default(''))" + + - name: Trigger ACME index task (one-shot reindex) + ansible.builtin.command: > + ldapadd -c -H {{ acme_db_url }} + -D "{{ acme_db_bind_dn }}" -w "{{ acme_db_bind_password }}" + -f {{ indextask_src }} + register: indextask_apply + failed_when: indextask_apply.rc != 0 and 'Already exists' not in (indextask_apply.stderr | default('') + indextask_apply.stdout | default('')) + changed_when: "'adding new entry' in (indextask_apply.stdout | default(''))" + + - name: Rewrite ACME database.conf to use DSDatabase + ansible.builtin.copy: + dest: "/var/lib/pki/{{ pki_instance_name }}/conf/acme/database.conf" + owner: pkiuser + group: pkiuser + mode: "0660" + content: | + class=org.dogtagpki.acme.database.DSDatabase + url={{ acme_db_url }} + authType=BasicAuth + bindDN={{ acme_db_bind_dn }} + bindPassword={{ acme_db_bind_password }} + baseDN={{ acme_db_basedn }} + register: db_conf + + - name: Restart pki-tomcatd + ansible.builtin.systemd: + name: "pki-tomcatd@{{ pki_instance_name }}" + state: restarted + when: db_conf is changed + + - name: Wait for ACME directory to respond + ansible.builtin.uri: + url: "https://{{ ca_fqdn }}:8443/acme/directory" + validate_certs: false + register: acme_dir + retries: 12 + delay: 5 + until: acme_dir.status == 200 + + - name: Phase 4 done — ACME persistence on 389-DS + ansible.builtin.debug: + msg: + - "ACME baseDN: {{ acme_db_basedn }}" + - "directory: https://{{ ca_fqdn }}:8443/acme/directory" diff --git a/deployment/playbooks/30-wrpac-profile.yml b/deployment/playbooks/30-wrpac-profile.yml new file mode 100644 index 00000000..739b9559 --- /dev/null +++ b/deployment/playbooks/30-wrpac-profile.yml @@ -0,0 +1,75 @@ +--- +- name: Deploy WRPAC certificate profile (CS-06 §7.4) + hosts: all + gather_facts: false + vars: + ca_cs_cfg: "/var/lib/pki/{{ pki_instance_name }}/conf/ca/CS.cfg" + profile_dir: "/var/lib/pki/{{ pki_instance_name }}/conf/ca/profiles/ca" + profile_file: "{{ profile_dir }}/{{ wrpac_profile_id }}.cfg" + + tasks: + + - name: Render WRPAC profile in place + ansible.builtin.template: + src: "../files/{{ wrpac_profile_id }}.cfg.j2" + dest: "{{ profile_file }}" + owner: pkiuser + group: pkiuser + mode: "0660" + register: profile_rendered + + - name: Register WRPAC profile class_id in CS.cfg + ansible.builtin.lineinfile: + path: "{{ ca_cs_cfg }}" + regexp: "^profile\\.{{ wrpac_profile_id }}\\.class_id=" + line: "profile.{{ wrpac_profile_id }}.class_id=caEnrollImpl" + owner: pkiuser + group: pkiuser + mode: "0660" + register: classid_registered + + - name: Read current profile.list + ansible.builtin.command: "grep -E '^profile.list=' {{ ca_cs_cfg }}" + register: profile_list_line + changed_when: false + + - name: Append WRPAC to profile.list if missing + ansible.builtin.lineinfile: + path: "{{ ca_cs_cfg }}" + regexp: "^profile\\.list=" + line: "{{ profile_list_line.stdout }},{{ wrpac_profile_id }}" + owner: pkiuser + group: pkiuser + mode: "0660" + when: wrpac_profile_id not in profile_list_line.stdout + register: list_registered + + - name: Restart pki-tomcatd to load profile + ansible.builtin.systemd: + name: "pki-tomcatd@{{ pki_instance_name }}" + state: restarted + when: profile_rendered is changed or classid_registered is changed or (list_registered is defined and list_registered is changed) + + - name: Wait for CA endpoint after restart + ansible.builtin.wait_for: + host: 127.0.0.1 + port: 8443 + timeout: 60 + + - name: Verify WRPAC profile is loaded via EE profileSelect + ansible.builtin.uri: + url: "https://{{ ca_fqdn }}:8443/ca/ee/ca/profileSelect?profileId={{ wrpac_profile_id }}" + validate_certs: false + return_content: true + register: profile_select + retries: 6 + delay: 5 + until: "profile_select.status == 200 and 'inputPluginListSet' in profile_select.content" + + - name: Assert profileSelect returned an EE form for the profile + ansible.builtin.assert: + that: + - profile_select.status == 200 + - "'wrpacCert' in profile_select.url or wrpac_profile_id in profile_select.url" + - "'Error' not in profile_select.content[:2000]" + fail_msg: "WRPAC profile EE form did not render — profile may not be loaded" diff --git a/deployment/playbooks/31-wrpac-smoke-direct.yml b/deployment/playbooks/31-wrpac-smoke-direct.yml new file mode 100644 index 00000000..9a6f5efd --- /dev/null +++ b/deployment/playbooks/31-wrpac-smoke-direct.yml @@ -0,0 +1,194 @@ +--- +- name: WRPAC direct-enrolment smoke test (CS-06 §7.4) + hosts: all + gather_facts: false + vars: + smoke_dir: /root/wrpac-smoke + nss_dir: "{{ smoke_dir }}/nssdb" + nss_password: Secret.123 + csr_conf: "{{ smoke_dir }}/csr.conf" + csr_file: "{{ smoke_dir }}/wrpac.csr" + key_file: "{{ smoke_dir }}/wrpac.key" + cert_file: "{{ smoke_dir }}/wrpac.pem" + admin_p12: "/root/.dogtag/{{ pki_instance_name }}/ca_admin_cert.p12" + + tasks: + + - name: Ensure smoke test working directory + ansible.builtin.file: + path: "{{ smoke_dir }}" + state: directory + owner: root + group: root + mode: "0700" + + - name: Write openssl CSR config for WRPAC Subject DN + ansible.builtin.copy: + dest: "{{ csr_conf }}" + owner: root + group: root + mode: "0600" + content: | + [ req ] + distinguished_name = dn + prompt = no + req_extensions = v3_req + + [ dn ] + CN = {{ wrpac_test_friendly_name }} + O = {{ wrpac_test_organisation_name }} + organizationIdentifier = {{ wrpac_test_organisation_identifier }} + C = {{ wrpac_test_country }} + + [ v3_req ] + subjectAltName = @san + + [ san ] + DNS.1 = {{ wrpac_test_san_dns }} + + - name: Generate RSA-3072 key and CSR + ansible.builtin.command: > + openssl req -new + -newkey rsa:3072 + -nodes + -keyout {{ key_file }} + -out {{ csr_file }} + -config {{ csr_conf }} + args: + creates: "{{ csr_file }}" + + - name: Show the generated CSR subject + ansible.builtin.command: "openssl req -in {{ csr_file }} -noout -subject -nameopt RFC2253" + register: csr_subject + changed_when: false + + - name: Assert CSR Subject contains organizationIdentifier + ansible.builtin.assert: + that: + - "('organizationIdentifier=' + wrpac_test_organisation_identifier) in csr_subject.stdout or ('2.5.4.97=' + wrpac_test_organisation_identifier) in csr_subject.stdout" + fail_msg: "Generated CSR lacks organizationIdentifier in Subject: {{ csr_subject.stdout }}" + + - name: Initialize pki client NSS DB + ansible.builtin.command: "pki -d {{ nss_dir }} -c {{ nss_password }} client-init --force" + args: + creates: "{{ nss_dir }}/cert9.db" + + - name: Import caadmin client cert into NSS DB + ansible.builtin.shell: | + set -e + if pki -d {{ nss_dir }} -c {{ nss_password }} nss-cert-find 2>/dev/null | grep -q "Nickname:"; then + echo "already imported" + exit 0 + fi + pki -d {{ nss_dir }} -c {{ nss_password }} \ + pkcs12-import \ + --pkcs12 {{ admin_p12 }} \ + --pkcs12-password '{{ pki_admin_password }}' + register: import_admin + changed_when: "'already imported' not in import_admin.stdout" + + - name: Discover admin cert nickname + ansible.builtin.shell: | + set -e -o pipefail + pki -d {{ nss_dir }} -c {{ nss_password }} nss-cert-find \ + | awk -F': ' '/Nickname:/ {print $2; exit}' + args: + executable: /bin/bash + register: admin_nickname_raw + changed_when: false + + - name: Set admin_nickname fact + ansible.builtin.set_fact: + admin_nickname: "{{ admin_nickname_raw.stdout | trim }}" + + - name: Export CA signing cert from Dogtag + ansible.builtin.command: > + pki-server cert-export ca_signing + --cert-file {{ smoke_dir }}/ca_signing.crt + args: + creates: "{{ smoke_dir }}/ca_signing.crt" + + - name: Import CA signing cert into NSS DB as trusted + ansible.builtin.shell: | + set -e -o pipefail + if pki -d {{ nss_dir }} -c {{ nss_password }} nss-cert-find \ + | grep -E "^\s+Nickname:\s+lab-ca-signing\s*$" >/dev/null; then + echo "already imported" + exit 0 + fi + pki -d {{ nss_dir }} -c {{ nss_password }} \ + nss-cert-import \ + --cert {{ smoke_dir }}/ca_signing.crt \ + --trust CT,C,C \ + "lab-ca-signing" + args: + executable: /bin/bash + register: ca_import + changed_when: "'already imported' not in ca_import.stdout" + + - name: Submit CSR for enrolment with profile {{ wrpac_profile_id }} + ansible.builtin.command: > + pki -d {{ nss_dir }} -c {{ nss_password }} + -n "{{ admin_nickname }}" + -U https://{{ ca_fqdn }}:8443 + ca-cert-request-submit + --profile {{ wrpac_profile_id }} + --csr-file {{ csr_file }} + register: request_submit + + - name: Show submission output + ansible.builtin.debug: + msg: "{{ request_submit.stdout_lines }}" + + - name: Assert request was auto-issued (agent submitter ⇒ complete on submit) + ansible.builtin.assert: + that: + - "'Request Status: complete' in request_submit.stdout" + - "'Operation Result: success' in request_submit.stdout" + - "'Certificate ID:' in request_submit.stdout" + fail_msg: "Request was not auto-issued — see submit output" + + - name: Capture Certificate ID + ansible.builtin.set_fact: + cert_id: "{{ request_submit.stdout | regex_search('Certificate ID:\\s*(\\S+)', '\\1') | first }}" + + - name: Remove any stale cert file + ansible.builtin.file: + path: "{{ cert_file }}" + state: absent + + - name: Export issued certificate to PEM + ansible.builtin.shell: | + pki -d {{ nss_dir }} -c {{ nss_password }} \ + -n "{{ admin_nickname }}" \ + -U https://{{ ca_fqdn }}:8443 \ + ca-cert-show {{ cert_id }} \ + --output {{ cert_file }} + args: + executable: /bin/bash + + - name: Dump issued cert (text) + ansible.builtin.command: "openssl x509 -in {{ cert_file }} -noout -text -nameopt RFC2253" + register: cert_text + changed_when: false + + - name: Show issued certificate + ansible.builtin.debug: + msg: "{{ cert_text.stdout_lines }}" + + - name: Verify CS-06 §7.4 attributes + ansible.builtin.assert: + that: + # #5 organizationIdentifier (OID 2.5.4.97) + - "'organizationIdentifier=' + wrpac_test_organisation_identifier in cert_text.stdout or '2.5.4.97=' + wrpac_test_organisation_identifier in cert_text.stdout" + # #3 legal name in O= + - "'O=' + wrpac_test_organisation_name in cert_text.stdout" + # #6 certificate policy OID + - wrpac_policy_oid_legal in cert_text.stdout + # #8 SAN extension with DNS + - "'DNS:' + wrpac_test_san_dns in cert_text.stdout" + # #13 1-year validity — check the cert is not 90-day + - "'Not After' in cert_text.stdout" + # #12 SHA-256+ signature + - "'sha256' in cert_text.stdout or 'sha384' in cert_text.stdout or 'sha512' in cert_text.stdout" + fail_msg: "Issued cert does not meet CS-06 §7.4 attribute checklist" diff --git a/deployment/playbooks/40-wrpac-facade.yml b/deployment/playbooks/40-wrpac-facade.yml new file mode 100644 index 00000000..97991ed9 --- /dev/null +++ b/deployment/playbooks/40-wrpac-facade.yml @@ -0,0 +1,184 @@ +--- +- name: Deploy WRPAC ACME façade + mock RP List service (CS-06 Phase 3) + hosts: all + gather_facts: false + vars: + src_root: "{{ playbook_dir }}/../files/wrpac-acme" + + tasks: + + - name: Ensure firewalld ports are open (façade + RP List) + ansible.posix.firewalld: + port: "{{ item }}" + permanent: true + immediate: true + state: enabled + loop: + - "{{ wrpac_facade_port }}/tcp" + - "{{ wrpac_rp_list_port }}/tcp" + + - name: Ensure target install directory + ansible.builtin.file: + path: "{{ wrpac_facade_root }}" + state: directory + owner: root + group: root + mode: "0755" + + - name: Ensure persistent state directory + ansible.builtin.file: + path: /var/lib/wrpac-acme + state: directory + owner: root + group: root + mode: "0700" + + - name: Remove any stale WRPAC_BASE_URL override (now derived from request) + ansible.builtin.file: + path: /etc/systemd/system/wrpac-acme-facade.service.d + state: absent + register: stale_override + + - name: Sync wrpac-acme source tree + ansible.builtin.copy: + src: "{{ src_root }}/" + dest: "{{ wrpac_facade_root }}/" + owner: root + group: root + mode: preserve + directory_mode: "0755" + register: src_sync + + - name: Create venv + ansible.builtin.command: "python3 -m venv {{ wrpac_facade_root }}/.venv" + args: + creates: "{{ wrpac_facade_root }}/.venv/bin/python" + + - name: Install Python dependencies into venv + ansible.builtin.pip: + name: + - "fastapi>=0.110,<0.116" + - "uvicorn[standard]>=0.27,<0.32" + - "pydantic>=2.5,<3" + - "cryptography>=42,<44" + - "jwcrypto>=1.5,<2" + - "httpx>=0.26,<0.28" + virtualenv: "{{ wrpac_facade_root }}/.venv" + state: present + + - name: Install wrpac-acme package itself (editable) + ansible.builtin.command: + cmd: "{{ wrpac_facade_root }}/.venv/bin/pip install -e ." + chdir: "{{ wrpac_facade_root }}" + register: pip_install_self + changed_when: "'Successfully installed' in pip_install_self.stdout" + + - name: Write systemd unit — RP List service + ansible.builtin.copy: + dest: /etc/systemd/system/wrpac-rp-list.service + owner: root + group: root + mode: "0644" + content: | + [Unit] + Description=WE BUILD mock RP List service + After=network.target + + [Service] + Type=simple + User=root + Environment="WRPAC_SEED_WRP_ID={{ wrpac_test_wrp_id }}" + Environment="WRPAC_SEED_LEGAL_NAME={{ wrpac_test_organisation_name }}" + Environment="WRPAC_SEED_ORG_ID={{ wrpac_test_organisation_identifier }}" + Environment="WRPAC_SEED_COUNTRY={{ wrpac_test_country }}" + Environment="WRPAC_RP_LIST_DB_PATH=/var/lib/wrpac-acme/rp_list.sqlite3" + ExecStart={{ wrpac_facade_root }}/.venv/bin/uvicorn wrpac_acme.rp_list:app \ + --host 0.0.0.0 --port {{ wrpac_rp_list_port }} + Restart=on-failure + + [Install] + WantedBy=multi-user.target + register: rp_list_unit + notify: Reload systemd + + - name: Write systemd unit — ACME façade + ansible.builtin.copy: + dest: /etc/systemd/system/wrpac-acme-facade.service + owner: root + group: root + mode: "0644" + content: | + [Unit] + Description=WE BUILD CS-06 ACME façade + After=network.target wrpac-rp-list.service + Wants=wrpac-rp-list.service + + [Service] + Type=simple + User=root + Environment="WRPAC_BASE_URL=http://{{ ca_fqdn }}:{{ wrpac_facade_port }}" + Environment="WRPAC_RP_LIST_URL=http://localhost:{{ wrpac_rp_list_port }}" + Environment="WRPAC_NSS_DIR=/root/wrpac-smoke/nssdb" + Environment="WRPAC_NSS_PASSWORD={{ pki_admin_password }}" + Environment="WRPAC_ADMIN_NICKNAME=PKI Administrator for lab" + Environment="WRPAC_CA_URL=https://{{ ca_fqdn }}:8443" + Environment="WRPAC_PROFILE_ID={{ wrpac_profile_id }}" + Environment="WRPAC_DB_PATH=/var/lib/wrpac-acme/facade.sqlite3" + ExecStart={{ wrpac_facade_root }}/.venv/bin/uvicorn wrpac_acme.facade:app \ + --host 0.0.0.0 --port {{ wrpac_facade_port }} \ + --proxy-headers --forwarded-allow-ips '*' + Restart=on-failure + + [Install] + WantedBy=multi-user.target + register: facade_unit + notify: Reload systemd + + - name: Flush handlers + ansible.builtin.meta: flush_handlers + + - name: Ensure RP List service running + restart if code or unit changed + ansible.builtin.systemd: + name: wrpac-rp-list + enabled: true + state: "{{ 'restarted' if (src_sync is changed or rp_list_unit is changed) else 'started' }}" + + - name: Ensure ACME façade running + restart if code or unit changed + ansible.builtin.systemd: + name: wrpac-acme-facade + enabled: true + state: "{{ 'restarted' if (src_sync is changed or facade_unit is changed) else 'started' }}" + + - name: Wait for RP List port + ansible.builtin.wait_for: + host: 127.0.0.1 + port: "{{ wrpac_rp_list_port }}" + timeout: 30 + + - name: Wait for façade port + ansible.builtin.wait_for: + host: 127.0.0.1 + port: "{{ wrpac_facade_port }}" + timeout: 30 + + - name: Sanity-check façade directory + ansible.builtin.uri: + url: "http://{{ ca_fqdn }}:{{ wrpac_facade_port }}/acme-eudi-wrpac/directory" + return_content: true + register: facade_directory + + - name: Assert directory advertises CS-06 extensions + ansible.builtin.assert: + that: + - "'wrp-id' in facade_directory.json.meta.supportedIdentifierTypes" + - "'registrar-api-01' in facade_directory.json.meta.supportedChallengeTypes" + - facade_directory.json.meta.externalAccountRequired + + # End-to-end smoke is covered by tests/test_04 (which runs from the + # control machine so it isn't affected by hairpin NAT when the directory + # advertises public URLs). The deploy playbook only verifies the directory. + + handlers: + - name: Reload systemd + ansible.builtin.systemd: + daemon_reload: true diff --git a/deployment/playbooks/99-smoke-test.yml b/deployment/playbooks/99-smoke-test.yml new file mode 100644 index 00000000..dbc8a147 --- /dev/null +++ b/deployment/playbooks/99-smoke-test.yml @@ -0,0 +1,106 @@ +--- +- name: ACME end-to-end smoke test + hosts: all + gather_facts: false + vars: + acme_dir: "https://{{ ca_fqdn }}:8443/acme/directory" + smoke_home: /root/acme-smoke + smoke_email: "smoke@{{ ca_fqdn }}" + smoke_identifier: "{{ ca_fqdn }}" + + tasks: + + - name: Open port 80 for http-01 challenge listener + ansible.posix.firewalld: + port: 80/tcp + permanent: true + immediate: true + state: enabled + + - name: Sanity-check ACME directory responds + ansible.builtin.uri: + url: "{{ acme_dir }}" + validate_certs: false + return_content: true + register: smoke_directory + + - name: Assert directory has the expected endpoints + ansible.builtin.assert: + that: + - smoke_directory.json.newNonce is defined + - smoke_directory.json.newAccount is defined + - smoke_directory.json.newOrder is defined + fail_msg: "ACME directory is missing required endpoints" + + - name: Ensure acme.sh is installed + ansible.builtin.command: "/root/.acme.sh/acme.sh --version" + register: acme_sh_check + changed_when: false + failed_when: false + + - name: Install acme.sh via curl bootstrap + when: acme_sh_check.rc != 0 + block: + - name: Install acme.sh dependencies + ansible.builtin.dnf: + name: + - curl + - socat + - openssl + state: present + + - name: Ensure acme.sh source dir + ansible.builtin.file: + path: /root/acme.sh-src + state: directory + mode: "0700" + + - name: Download acme.sh installer + ansible.builtin.get_url: + url: "https://raw.githubusercontent.com/acmesh-official/acme.sh/master/acme.sh" + dest: /root/acme.sh-src/acme.sh + mode: "0700" + validate_certs: true + + - name: Run acme.sh installer + ansible.builtin.command: "./acme.sh --install -m {{ smoke_email }}" + args: + chdir: /root/acme.sh-src + creates: /root/.acme.sh/acme.sh + + - name: Register ACME account (idempotent — acme.sh skips if already registered) + ansible.builtin.command: > + /root/.acme.sh/acme.sh + --server {{ acme_dir }} + --register-account + --insecure + -m {{ smoke_email }} + register: acme_register + changed_when: "'Already registered' not in acme_register.stdout" + + - name: Issue a certificate via standalone http-01 (RSA-2048 — see acmeServerCert profile) + ansible.builtin.command: > + /root/.acme.sh/acme.sh + --server {{ acme_dir }} + --issue + --standalone + --insecure + --keylength 2048 + -d {{ smoke_identifier }} + --force + register: acme_issue + changed_when: true + + - name: Show issued certificate path + ansible.builtin.command: "ls /root/.acme.sh/{{ smoke_identifier }}_ecc/ /root/.acme.sh/{{ smoke_identifier }}/" + register: cert_dir + changed_when: false + failed_when: false + + - name: Print smoke-test summary + ansible.builtin.debug: + msg: + - "ACME directory: {{ acme_dir }}" + - "Test identifier: {{ smoke_identifier }}" + - "Issued cert dir: {{ cert_dir.stdout_lines | default([]) }}" + - "Last acme.sh exit: {{ acme_issue.rc }}" diff --git a/deployment/requirements.txt b/deployment/requirements.txt new file mode 100644 index 00000000..0cb2ed59 --- /dev/null +++ b/deployment/requirements.txt @@ -0,0 +1 @@ +ansible-core>=2.16,<2.18 diff --git a/deployment/requirements.yml b/deployment/requirements.yml new file mode 100644 index 00000000..e079b929 --- /dev/null +++ b/deployment/requirements.yml @@ -0,0 +1,6 @@ +--- +collections: + - name: ansible.posix + version: ">=1.5.0,<3.0.0" + - name: community.general + version: ">=8.0.0,<11.0.0" diff --git a/deployment/site.yml b/deployment/site.yml new file mode 100644 index 00000000..d2fc5a6d --- /dev/null +++ b/deployment/site.yml @@ -0,0 +1,10 @@ +--- +- import_playbook: playbooks/00-prereqs.yml +- import_playbook: playbooks/10-dogtag-ca.yml +- import_playbook: playbooks/20-dogtag-acme.yml +- import_playbook: playbooks/21-acme-persistence.yml + when: acme_database_backend | default('in-memory') == 'ds' +- import_playbook: playbooks/30-wrpac-profile.yml +- import_playbook: playbooks/31-wrpac-smoke-direct.yml +- import_playbook: playbooks/40-wrpac-facade.yml +- import_playbook: playbooks/99-smoke-test.yml diff --git a/deployment/test-out/dogtag-ca-root.pem b/deployment/test-out/dogtag-ca-root.pem new file mode 100644 index 00000000..bf72689a --- /dev/null +++ b/deployment/test-out/dogtag-ca-root.pem @@ -0,0 +1,27 @@ +-----BEGIN CERTIFICATE----- +MIIEljCCAv6gAwIBAgIRAOUZGwH/qWUU8lDDpXnDfSAwDQYJKoZIhvcNAQELBQAw +VDEcMBoGA1UECgwTbGFiIFNlY3VyaXR5IERvbWFpbjETMBEGA1UECwwKcGtpLXRv +bWNhdDEfMB0GA1UEAwwWQ0EgU2lnbmluZyBDZXJ0aWZpY2F0ZTAeFw0yNjA1MTkw +OTU5MzFaFw00NjA1MTkwOTU5MzFaMFQxHDAaBgNVBAoME2xhYiBTZWN1cml0eSBE +b21haW4xEzARBgNVBAsMCnBraS10b21jYXQxHzAdBgNVBAMMFkNBIFNpZ25pbmcg +Q2VydGlmaWNhdGUwggGiMA0GCSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQDWJtWL +5K+ZhqQ5Ef/80ZU0ChfcDy5yLcf0JYEgPTBG7Rnrc2RbdqFnZEL7GmAPfGhchAzl +sWuZ6VgwB2gUfldnugH+nON+u4OumVc4pmx0zDqhooJmevZlm7/npVmmL2zPVB4R +fjAf1KOD62luFhucIGh+loGVT5Mspxx/zahjadfqjqEM5ef7KJ/57HA9NPrRU6wB +mpn6DuqZKOUWnk0Pm2Se7+0YfVoiBl0UxemvOXHt5HIToLSytLrxJSL1fy31KgvV +M56ttrGoTjA3EHINi6XYxFkxmfpFpOy4V0fpW1uIUSGmfv/bkOidQvqmXvriPMCD +BVU6p/0oszxYPe9AQbaTkEUIq3eo62ANMtLT6p15WRRV+O63A8w7LEKB7V0XLY7C +B0L1PFCmdKcDyEYAEoq1cXubdT1PaR8GGcGt2Hw9a9eGAzu+edh8KAIGBbUHW6fM +uHmMBKTbK1tKmTGMEauiSgcHqFfqrTKtAA2AF67clDOs6K8z0NXBRaog1B8CAwEA +AaNjMGEwHQYDVR0OBBYEFH9fCueXVdjrb7JHonbQ/mrys2xEMB8GA1UdIwQYMBaA +FH9fCueXVdjrb7JHonbQ/mrys2xEMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/ +BAQDAgHGMA0GCSqGSIb3DQEBCwUAA4IBgQDGOLThN0Soe+XDflnYWcWu7JPRgMrc +yxS7gg5i0dzmB2iIvuQJ/SNtg0PSuK95rktTnPEwadFDnmkhN+nNGdL50QLLwI9H +AeeSJHP/xUzz2MLykgYp6tx0U56gUh8N1B9rrPOaZ+UCa7QjGUs3/lczrBVbJwlT ++YpqJ/VUlTFIhsNast8qwHSoO9vX+Lyt8WINe5vSZ9oUAstQNDKvXGI68IIyZt0x +t1kIm+feaiFVcdy2RjbuB1qJO8JhWYTHPbnKvy7D7LZ+iLtuKS9idd+2VpGrBrcd +6WIi52Tg7ZE0p3ZsaP99+aCLn6BxDisbzy8H6vx7FFnZfZDMF1Sv6fGD38e9KirI +Mgvjb4N7Gg7lJCEpBOLZ1zDRmSqInta0Mr99+agt3MNEetmkUJkJLnTZa0hatT/v +rhjcGNYh20BLKNU5KMBxUzpGLU5abZuCUGFGBNASp8qJVjBovanR+TLL7ji2z7hS +tjD/zOxvwCAxmubPWT9xlCgbxZeCqyz+8tY= +-----END CERTIFICATE----- diff --git a/deployment/test-out/phase3-1779199136.pem b/deployment/test-out/phase3-1779199136.pem new file mode 100644 index 00000000..b0e7bb22 --- /dev/null +++ b/deployment/test-out/phase3-1779199136.pem @@ -0,0 +1,26 @@ +-----BEGIN CERTIFICATE----- +MIIFOzCCA6OgAwIBAgIQG7ogxQRLXFjSXLq7dJRj6TANBgkqhkiG9w0BAQsFADBUMRwwGgYDVQQK +DBNsYWIgU2VjdXJpdHkgRG9tYWluMRMwEQYDVQQLDApwa2ktdG9tY2F0MR8wHQYDVQQDDBZDQSBT +aWduaW5nIENlcnRpZmljYXRlMB4XDTI2MDUxOTEzNTg1OVoXDTI3MDUxOTEzNTg1OVowbTELMAkG +A1UEBhMCTkwxGzAZBgNVBGEMElZBVE5MLTAwMDAwMDAwMEIwMTEkMCIGA1UECgwbQUNNRSBUZXN0 +IE9yZ2FuaXNhdGlvbiBCLlYuMRswGQYDVQQDDBJBQ01FIFJQIFNtb2tlIFRlc3QwggGiMA0GCSqG +SIb3DQEBAQUAA4IBjwAwggGKAoIBgQDHRlGzjQzOrtRN9YSDAvmEsQRN+eJjy0sP1BdhvP5XEBMt +mUaoPvpWxboqwgSXHF+FT1W9L7CCfLBNGLPHTY5wqCDVeUsmQAr2d53HnGBAwNbGf4bKrF6obYfK +eUqBRbH9v5m/puiXJUF4q3eDqDr63LvNCdsNWrt68LQdzsEU7UYPy1y2wzAZ2aqS1J0GP3dvXOvM +sUqydos4LNxlQcTQUjUg+h6PBZnS3aiKKpUOF7gcy3vqrS1KEp6NWuCq59JNj4VCKBYTBNO6paPZ +mZfcQUFEko32NIUVnRMV4N6gwHONSrcbqDILMsCg8v34DKGHQ2x/25sfADABpMocXXSIAjf5Emp+ +nZ/k8blSzJSQfUVTkT1Z4pwaklojwQ6layv9p8grm7MVXQmq1O5kKINy256o8RtoAbxZ4i2dFsd3 +de8aEfc5GD8C+rGn7TJ/o5+ZdeaqhDHTfgXmr1xW1SaW0TpcC1r+qfx2pmzGuSYGwtO3PbWrS3WG +tQIyI+1jD7kCAwEAAaOB7zCB7DAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwIG +CCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUf18K55dV2OtvskeidtD+avKzbEQw +PgYIKwYBBQUHAQEEMjAwMC4GCCsGAQUFBzABhiJodHRwOi8vbXctdm0tY2EwMS5sYWI6ODA4MC9j +YS9vY3NwMBYGA1UdEQQPMA2CC3JwLnRlc3QubGFiMB0GA1UdDgQWBBSFjORturoGpExBG19LXMJs +RJIeezAVBgNVHSAEDjAMMAoGCAQAi+xAAWMBMA0GCSqGSIb3DQEBCwUAA4IBgQCt/a7A2Gikn+Wl +L6PnG04KOxVYrN1u3kUJ+nKNOqx4sKcjDLc91Xz4hiPHk0FWU2TVkwIOiITalTuN2RWBvzSd4vzL +z8YzDoWKsBYc525ph7/Tl9rGtOBD1l+aFMxLqSIY+tPme9ojl9uTU65xjaWDDe/VlxvNs2t5WWhY +WEGU9BaS2PPiKV9BrEfroqzLRjWO3z5Juon2cdZc2EUwQvIFM1LbaC9XnOjVm9yPVIw+GiAZbAbq +hGjyqW1KiP3zjaiVEeIAmtNP3vmlf3yYZYVqBcS0nW9+TdZa8RXiIDfzFrkW0YIYXEV8yo5e1PVs +/aey1ia/oU4ixmoyTppA6WfWvJzztc4OuCm8yQGH+evMP54xQbZlf4hI6AxUUb8RqCFYMchZAmyo +/vMcSmDrhROxbdB2AeqD/O8wWvV7+8tQCv3tY+sQ5ykV+egimiySXFmudx7SMdMXClaxQMY1tUQq +EB5UO/op0Dnph/nu43y8fgamClq+IliGqglrLGb7nuc= +-----END CERTIFICATE----- diff --git a/deployment/test-out/phase3-1779199294.pem b/deployment/test-out/phase3-1779199294.pem new file mode 100644 index 00000000..8e46f223 --- /dev/null +++ b/deployment/test-out/phase3-1779199294.pem @@ -0,0 +1,26 @@ +-----BEGIN CERTIFICATE----- +MIIFOzCCA6OgAwIBAgIQWepnqmy9KsgZqSR4TzyewTANBgkqhkiG9w0BAQsFADBUMRwwGgYDVQQK +DBNsYWIgU2VjdXJpdHkgRG9tYWluMRMwEQYDVQQLDApwa2ktdG9tY2F0MR8wHQYDVQQDDBZDQSBT +aWduaW5nIENlcnRpZmljYXRlMB4XDTI2MDUxOTE0MDEzN1oXDTI3MDUxOTE0MDEzN1owbTELMAkG +A1UEBhMCTkwxGzAZBgNVBGEMElZBVE5MLTAwMDAwMDAwMEIwMTEkMCIGA1UECgwbQUNNRSBUZXN0 +IE9yZ2FuaXNhdGlvbiBCLlYuMRswGQYDVQQDDBJBQ01FIFJQIFNtb2tlIFRlc3QwggGiMA0GCSqG +SIb3DQEBAQUAA4IBjwAwggGKAoIBgQDBFZS5KFYpIdyX4ZTtsQzLjogFn/4vDiufSfBZsNNPIonr +6KQMXZtDVVqHnfInFnvPC7Qqxa5fTZtYSJ9TqWoZLYAW7gve0qHeQnFR3J/j0UUbZDaOsf278vGR +Ntzf50bwBZpk3+bZEDr5wwuaj5CnMPI2KxWCQbIYrcYlMvTcnqT9DAhjK+fyqXdHKWJFyMFtxLB0 +1zEyjVNWc8Nm0TarPUKTEj1UPd4HnYBZ+RNeG2HcZt8IJIOHWutHyXEppif29inUjHi5Y12QK0bC +qdXsBzJN1bq54aZMhoxV+RGCEw/qvouDmkaYjb1BMLdlaGPZtbnsEFt7IxrbzazP/GW5lA3fwqyW +/EMFOfDyqFpA5c906haQxocaVM/XYBTvLDYIdq5GtvLiEC8GoCv8vLZ+YiIkJ2uZ7qRdnS8bj5V3 +Ql1T/OtYdbByUn2qs8j6XnNW3EFgBvGOyWP+udEVxNRUP0hNFxhpZTUJIMhQ70YOqrI8I7ikCP1C +9Yuhjl1LYKsCAwEAAaOB7zCB7DAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwIG +CCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUf18K55dV2OtvskeidtD+avKzbEQw +PgYIKwYBBQUHAQEEMjAwMC4GCCsGAQUFBzABhiJodHRwOi8vbXctdm0tY2EwMS5sYWI6ODA4MC9j +YS9vY3NwMBYGA1UdEQQPMA2CC3JwLnRlc3QubGFiMB0GA1UdDgQWBBT2S6bifaMaCkiXFPbuvmuT +87DrEDAVBgNVHSAEDjAMMAoGCAQAi+xAAWMBMA0GCSqGSIb3DQEBCwUAA4IBgQBBn747ys4c6d8e +vA2LypslEfJMYpoGtjXdM3EH9w5yQ9SserUO0QID7G/oGlOesg9Nc/7M1VXW0hwFMzNySK4Ts61f +J1aJDxnBh1xDiwznLcdq3oUADfkp1Ymx1kEO7ld+YoGa/s5dBH8qxMT3qUFy0e8oR+BYJA5KlWOw +D6jy50rzh0ADb3mPeFh9AXnKXC9FwGC10lbm8uIkFFozjVmEdplqgewKBYDw4r8JzCanYe/c6i3h +TP5XzbWqGuovuhu4b+S6hoG7qVZ9rL7Ps06lSjIsS8Z8Fii8TsZRqLIpN7d+wbuNYwwvAyZksNbn +I8amT/dCjgRA7lNQmvTa9e4gBTmMpyPu7Nej1NozypaOf3BMBof/JxQbNJrCL1njdKKCCB7yl6w3 +GNF1M36IzBVcJSm2QA8ghw9ylUjQSl36Lz+f++HoPRahSytHHaiwydMJaLu+4IPxYiJfJ7cjoRjM +YbOzFOfWhRV9LnrbTN9iIXCATWzlo+osYwfMuWB1efs= +-----END CERTIFICATE----- diff --git a/deployment/test-out/phase3-1779201344.pem b/deployment/test-out/phase3-1779201344.pem new file mode 100644 index 00000000..9136484c --- /dev/null +++ b/deployment/test-out/phase3-1779201344.pem @@ -0,0 +1,26 @@ +-----BEGIN CERTIFICATE----- +MIIFPDCCA6SgAwIBAgIRANyJm8/0vB6+91U0hjps0CkwDQYJKoZIhvcNAQELBQAwVDEcMBoGA1UE +CgwTbGFiIFNlY3VyaXR5IERvbWFpbjETMBEGA1UECwwKcGtpLXRvbWNhdDEfMB0GA1UEAwwWQ0Eg +U2lnbmluZyBDZXJ0aWZpY2F0ZTAeFw0yNjA1MTkxNDM1NDZaFw0yNzA1MTkxNDM1NDZaMG0xCzAJ +BgNVBAYTAk5MMRswGQYDVQRhDBJWQVROTC0wMDAwMDAwMDBCMDExJDAiBgNVBAoMG0FDTUUgVGVz +dCBPcmdhbmlzYXRpb24gQi5WLjEbMBkGA1UEAwwSQUNNRSBSUCBTbW9rZSBUZXN0MIIBojANBgkq +hkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAuVrciqdCxSbNoAqmYqrcUMXFBAV8psOwzn9I5k3u+/hg +MrWq8d8hzXMFfoK7Vb3F3b2c9PXDiSuu32TsT4OzObWLPX4u0KftbUba7TrTw/0akhDI2MhhHuav +PxzWNcvv0o2/OG0lNEeQlVQ//eUr7mgcCQduUT/uI6QJZgEq7fcMzV2NQD27kQBgpa+JI101YgP9 +J0M6rKLwgbIs5W4GMbx+gepEJWnUQhwmeS9u4uwCDjlAy/IOUx5xjaYHy26q7zVYVAaSW4UscOmU +wZn+HVx5hTC5ZvPrs9F9fsAHzSeGStdtAK+IMxVaqiZ/m5F5MyYffwzPJmUqdDLaM6H9ovkx3M58 +oEWjy+oEZmRuSdDAe2+BeSkDjqnHmqnmcZwQr0OAKucXbTDX4cbwLb/aUffCc1J+VtSoTU4BU9rf +GIFMh40aQs2DjLrW/2XtqXVBrDrGHhM0vhai03Jh4NkZ8GspWRV+qf9xAOHCVl7EX3xuQaeq9lzE +NTxC2iYJejgXAgMBAAGjge8wgewwDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMC +BggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFH9fCueXVdjrb7JHonbQ/mrys2xE +MD4GCCsGAQUFBwEBBDIwMDAuBggrBgEFBQcwAYYiaHR0cDovL213LXZtLWNhMDEubGFiOjgwODAv +Y2Evb2NzcDAWBgNVHREEDzANggtycC50ZXN0LmxhYjAdBgNVHQ4EFgQUAOGlMlzjZD/rvKu1sAmH +iuF3IJQwFQYDVR0gBA4wDDAKBggEAIvsQAFjATANBgkqhkiG9w0BAQsFAAOCAYEAfYV3pF05Npxo +plhfQGf4APpcx/ll2RORy7afSI683QUnXXQ3AX+M96KbGHzJaVEPh2S1O8PjDgvLPt76lhEjrQoD +iUxnfqoSnujkdGV5Hdt0q7Tnq0HhJTfh+oTTEi19QJSw/ED49P1f8juBZxmBZqMNcDe5LdnRYDlg +RIXIvo7nzLlm0pUW5JRF7E8EX7IsvRqLPpUyfNpd3JPJmmBhCAagyxsEf6FM9Y3V2Mhwhjc14zTW +KiCSLdxvNFEUE6HxiVioosZmU/v2vsfnpJfxtmeY+R5adRzEUovnPuZtM3J7BI4d8v50nAqxvW/N +gsTJ+KLDFBBV7H+Lhbsi2UbNpl/5f6Gk76W5EZ5nABrvay07HFs1qsRoEcKc9fiE6bqzx3GYy2/t +IEe36Xkx4p5iFYEmOkl6a5CfxvRBQOcXOIHVVlxyig1ZYzXJfn1P5oBseMoutbVqDsumBvTL1IzD +qGGptFbKFACQfZtI1DaEtM5YMuFGnWJ0cs+/VQgZG5I+ +-----END CERTIFICATE----- diff --git a/deployment/test-out/phase3-1779202392.pem b/deployment/test-out/phase3-1779202392.pem new file mode 100644 index 00000000..4815f015 --- /dev/null +++ b/deployment/test-out/phase3-1779202392.pem @@ -0,0 +1,26 @@ +-----BEGIN CERTIFICATE----- +MIIFPDCCA6SgAwIBAgIRAKdq2lYiKHzy52mbWc2bJ1EwDQYJKoZIhvcNAQELBQAwVDEcMBoGA1UE +CgwTbGFiIFNlY3VyaXR5IERvbWFpbjETMBEGA1UECwwKcGtpLXRvbWNhdDEfMB0GA1UEAwwWQ0Eg +U2lnbmluZyBDZXJ0aWZpY2F0ZTAeFw0yNjA1MTkxNDUzMTVaFw0yNzA1MTkxNDUzMTVaMG0xCzAJ +BgNVBAYTAk5MMRswGQYDVQRhDBJWQVROTC0wMDAwMDAwMDBCMDExJDAiBgNVBAoMG0FDTUUgVGVz +dCBPcmdhbmlzYXRpb24gQi5WLjEbMBkGA1UEAwwSQUNNRSBSUCBTbW9rZSBUZXN0MIIBojANBgkq +hkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAyP0pCC0JIVzOZaz/PvAXSnEqnhov6B8g9XCDXgUnj1nx +iA0d5aX6Jb14jpxJaWuiUp5C2zIBlaGjbHL/k5qbNRtE7yo71IQsf7IiMuljeAV/YRTrwDSlaEMJ +bZ/Lyj+Ou4tcVAKdqHZ4YX9LQ7u/1wFgu0smch6aCY4SBRs17RFN3jfLElYxSr3XZUH5UIY2Krrd +KzE/xDJfBwbvZBzhxexrRYFoW6fZP1u/cIoxSQ3DENNOpBi/hR85QbVzz2vTzeBNFMY/h9jktAuh +dnlPxa0xTXg1RGgUb0Tn9VyrnGP8pg/FyQXvOBF8lrUhw5VqrZJvEzXjVSbKshnW6iSKcM+EE1cY +wCCFtxBCLLXX0i4kqvPU1nKQZaf0uOTR8rFN9Mu/Z1wexmj5XTXl9RoqeG4kIqUO5W7hWDnd76+s +AM0ZwVZwOv353KvmoUQok4LCrhjKdOoUZHP1ZeL5CoCBX1Hq6B6JaJ9MpnDg3HjX8bNilFNqQ2wE +2zQAa3RwsQpDAgMBAAGjge8wgewwDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMC +BggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFH9fCueXVdjrb7JHonbQ/mrys2xE +MD4GCCsGAQUFBwEBBDIwMDAuBggrBgEFBQcwAYYiaHR0cDovL213LXZtLWNhMDEubGFiOjgwODAv +Y2Evb2NzcDAWBgNVHREEDzANggtycC50ZXN0LmxhYjAdBgNVHQ4EFgQUUbCiie4zcu6nLz3lzDGp +1V8HnqIwFQYDVR0gBA4wDDAKBggEAIvsQAFjATANBgkqhkiG9w0BAQsFAAOCAYEAWoYIblGoBwJX +4kGjiJdG7ytkiTg6/e8Q4N2BVqaNOevzNBwR7qts+ZwxcCtlSFb+MJypXr3ma1WOR787iTxHlrLL +5ztmkalWeoJNWbqce3FkvqDWT6PcptxGrnm3PHFOMqeoOy2tz2GzTov+5dIkgMxMFp7Xb7C8ejXy +E/VH7w6DRxJUCH1bWuuVnLtMjRY2VKeNi6W2uU9o4wqTiIuQiFyIykdyybC81fMlvu1zlwzeXB87 +XSp+i7xM3EIwDYh5PUp7hWBGM0I0sruiEn6GoVASWr47nwqFq6hNSLFkklrAgEgMurf6aZ/GR72g +Uv0Z/LO4HLhCZTBTr176MwkRaSaCVQibIEUtL9SOLpYFJsiNxJrunEQ6U/R5GEdj7yuGownl0bpC +XcIN4W5YdxHZ831wUR3WeH5R3U+UcwtwGP0ZeIBF8rH/bHvn5VN+kwEGJb3NewYzanVcJ9wK2m4o +rS7iK401Psi6d0M1gyblTyckdbYRk84CNUrCMydXV/l/ +-----END CERTIFICATE----- diff --git a/deployment/test-out/profile-check.pem b/deployment/test-out/profile-check.pem new file mode 100644 index 00000000..45dda551 --- /dev/null +++ b/deployment/test-out/profile-check.pem @@ -0,0 +1,26 @@ +-----BEGIN CERTIFICATE----- +MIIFOjCCA6KgAwIBAgIQZFkZW3rZeZdXY2DS/H2gmDANBgkqhkiG9w0BAQsFADBUMRwwGgYDVQQK +DBNsYWIgU2VjdXJpdHkgRG9tYWluMRMwEQYDVQQLDApwa2ktdG9tY2F0MR8wHQYDVQQDDBZDQSBT +aWduaW5nIENlcnRpZmljYXRlMB4XDTI2MDUxOTE0NTMyMloXDTI3MDUxOTE0NTMyMlowbDELMAkG +A1UEBhMCTkwxGzAZBgNVBGEMElZBVE5MLTAwMDAwMDAwMEIwMTEkMCIGA1UECgwbQUNNRSBUZXN0 +IE9yZ2FuaXNhdGlvbiBCLlYuMRowGAYDVQQDDBFDUy0wNiDCpzcuNCBjaGVjazCCAaIwDQYJKoZI +hvcNAQEBBQADggGPADCCAYoCggGBAMVtDZPv5LF+Walxx5ZKWSAdUiLyPGBewN/z4ChSPtrmSGkZ +YqTYOHUsDzfM+ftWu5hyq4QFn5wA5tyhl/A+4I4fQXZH8psxgYdpoVUMQ1qj37vX5uIcMcy3El5+ +ISSHm/zHCBXnMk4U0QALq3ihHt+evL1S4OIqKpyTa4CYjtCH2M9qE47uNKDke+iEWH2WUo+XlD6/ +wRhmtyLtzMcIT5gSJ7CQ6iG8WjQe1B8hSlvll/3glsUHU4wHciTjLsXmTDyl9rtadTsjOc6CCaKB +VtxOnDGwkiTYBlF8Ae5imk+h3+GJJYG3dzoweaDYU4RiCZj18nAhjaFy3ZehwzEmR2/5R492HWhu +V9Iuk0H/2FysqrETdGGtbgch7bnDRDgLwhVXKcRsKAXZoWRqxqnP4/DaFxREwMTL1c79gEiGXpSQ +zvPXEUwmA5/q4gVb+UjbDdWbn1B7+B9NuUJ5qOWzDiBMwaZPJwsbcmgQoGl3HSFsWZEbJRQuWUGb +og40tveKQwIDAQABo4HvMIHsMA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAgYI +KwYBBQUHAwEwDAYDVR0TAQH/BAIwADAfBgNVHSMEGDAWgBR/Xwrnl1XY62+yR6J20P5q8rNsRDA+ +BggrBgEFBQcBAQQyMDAwLgYIKwYBBQUHMAGGImh0dHA6Ly9tdy12bS1jYTAxLmxhYjo4MDgwL2Nh +L29jc3AwFgYDVR0RBA8wDYILcnAudGVzdC5sYWIwHQYDVR0OBBYEFILr1rHaQksFsUGiiuzpdDzA +vIklMBUGA1UdIAQOMAwwCgYIBACL7EABYwEwDQYJKoZIhvcNAQELBQADggGBAHWDMH6Zg7F7viAJ +VHyuwiDIC7LyLar7CFAZBUwjufnTmNUoP6T178d6qYlA6CXM7fx6o+0M7nGFORZcFThmCFBxcnpf +MwFJHVmqw32hOJVJaTCDqhT8Y1vek5cfp9nmJ+N8WA91Jh1dIehTWGjF+3zV8kOEa73jBEH71PGO +5JaxWcZl4+eLS86lD3xe6p4yQY9md0Yt74S5kRhYzDYszw/CTrxc3L9g3RxFjFzfRa8ll7nFZJHo +KUoQZU+SsVN9frKf73iBRqNVhJRceBNGy2lmBO+jyITFR3fiGFv9d49A+kjjTs2W2eLk/cY/wKZq +vFMF8kFydSrB/duLjNERESF27FW0PWGtBc1TUqfGXBIDV82Yx5OXwRAjy6GK1vC/ZkfrOVnjWgrm +JvSqhVsKPHDLI4GLTxy52nEVJWrTngcwWIyzWtg2TVal2h6KIokYm4mcNEokzngVNw56Ihw2R0An +BBR2wcBAzGqAgPqFk89KxXlV2yu2F5Tr5J5odw2BcQ== +-----END CERTIFICATE----- diff --git a/deployment/tests/README.md b/deployment/tests/README.md new file mode 100644 index 00000000..e322b05e --- /dev/null +++ b/deployment/tests/README.md @@ -0,0 +1,45 @@ +# Tests + +This directory holds the test suite for the CS-06 CA deployment. Each test is a +standalone ansible playbook that runs on the CA host (`mw-vm-ca01.lab`) and can +target either the local or the public ACME endpoints via a single variable. + +## Test matrix + +| Test | What it covers | Targetable URL | +|---|---|---| +| `test_01_directory.yml` | Both ACME directories + RP List service respond and advertise the expected URLs/metadata | local **or** public | +| `test_02_phase1_acme.yml` | Stock RFC 8555 ACME issuance via `acme.sh` http-01 (Dogtag's built-in responder) | **local only** — needs the validator to reach back to the client | +| `test_03_phase2_direct.yml` | Direct CA enrolment with `pki ca-cert-request-submit --profile wrpacCert` and a WRPAC-shaped CSR | **local only** | +| `test_04_phase3_happy.yml` | Full CS-06 §6 happy path: `wrp-id` order → `registrar-api-01` challenge → finalize → cert download | local **or** public | +| `test_05_phase3_negative.yml` | Negative-test battery for CS-06 §7.2 server MUST clauses (missing EAB, unknown wrp-id, wrong key-auth, Subject mismatch, etc.) | local **or** public | +| `test_06_cert_profile.yml` | Parses a freshly-issued WRPAC and asserts every CS-06 §7.4 attribute | local **or** public | +| `test_07_persistence.yml` | Registers an ACME account, restarts `pki-tomcatd`, re-issues with the same account — proves the `ds` backend persists state | **local only** (uses acme.sh + systemctl) | +| `test_08_facade_persistence.yml` | Snapshots façade SQLite row counts, restarts `wrpac-acme-facade` + `wrpac-rp-list`, asserts counts unchanged — proves the Phase 3 façade & RP List persist state | **local only** (uses sqlite3 + systemctl) | + +## Run from `deployment/` (one level up) + +```sh +. .venv/bin/activate + +# Local (internal) URL — default +ansible-playbook tests/test_all.yml + +# Public URL +ansible-playbook tests/test_all.yml \ + -e test_acme_directory=https://rpca.lab.cleverbase.io/acme-eudi-wrpac/directory \ + -e test_rp_list=https://rpca.lab.cleverbase.io \ + -e test_acme_stock_directory=https://rpca.lab.cleverbase.io/acme/directory + +# Single test +ansible-playbook tests/test_05_phase3_negative.yml -e test_acme_directory=... +``` + +## Variables + +| Var | Default | Used by | +|---|---|---| +| `test_acme_directory` | `http://{{ ca_fqdn }}:{{ wrpac_facade_port }}/acme-eudi-wrpac/directory` | tests 1, 4, 5, 6 | +| `test_rp_list` | `http://{{ ca_fqdn }}:{{ wrpac_rp_list_port }}` | tests 1, 4, 5, 6 | +| `test_acme_stock_directory` | `https://{{ ca_fqdn }}:8443/acme/directory` | test 1 | +| `test_out_dir` | `/root/test-out` | tests 4, 6 | diff --git a/deployment/tests/test_01_directory.yml b/deployment/tests/test_01_directory.yml new file mode 100644 index 00000000..f4b25f0c --- /dev/null +++ b/deployment/tests/test_01_directory.yml @@ -0,0 +1,82 @@ +--- +# Test 01 — directory + nonce + RP List sanity. +# Verifies all three public surfaces respond and advertise the expected URLs. + +- name: "Test 01: directories + RP List sanity" + hosts: localhost + connection: local + gather_facts: false + vars: + test_acme_directory: "http://{{ ca_fqdn }}:{{ wrpac_facade_port }}/acme-eudi-wrpac/directory" + test_rp_list: "http://{{ ca_fqdn }}:{{ wrpac_rp_list_port }}" + test_acme_stock_directory: "https://{{ ca_fqdn }}:8443/acme/directory" + + tasks: + + - name: GET stock Dogtag ACME directory + ansible.builtin.uri: + url: "{{ test_acme_stock_directory }}" + validate_certs: false + return_content: true + register: stock_dir + + - name: Assert stock ACME directory carries the RFC 8555 endpoints + ansible.builtin.assert: + that: + - stock_dir.status == 200 + - stock_dir.json.newNonce is defined + - stock_dir.json.newAccount is defined + - stock_dir.json.newOrder is defined + - stock_dir.json.revokeCert is defined + fail_msg: "stock ACME directory missing required endpoints" + + - name: GET CS-06 façade directory + ansible.builtin.uri: + url: "{{ test_acme_directory }}" + validate_certs: false + return_content: true + register: facade_dir + + - name: Assert façade directory advertises CS-06 §5 extensions + ansible.builtin.assert: + that: + - facade_dir.status == 200 + - "'wrp-id' in facade_dir.json.meta.supportedIdentifierTypes" + - "'registrar-api-01' in facade_dir.json.meta.supportedChallengeTypes" + - facade_dir.json.meta.externalAccountRequired + fail_msg: "façade directory missing CS-06 §5 markers" + + - name: Façade newNonce returns a Replay-Nonce header + ansible.builtin.uri: + url: "{{ facade_dir.json.newNonce }}" + method: HEAD + validate_certs: false + status_code: [200, 204] + register: facade_nonce + + - name: Assert Replay-Nonce header present + ansible.builtin.assert: + that: + - "'replay-nonce' in (facade_nonce | dict2items | map(attribute='key') | list) or 'Replay-Nonce' in facade_nonce or facade_nonce.replay_nonce is defined" + fail_msg: "newNonce did not return a Replay-Nonce header" + ignore_errors: true # Header capture varies across ansible versions; the uri 200 already proves it works + + - name: GET seeded RP List entry + ansible.builtin.uri: + url: "{{ test_rp_list }}/rp-list/{{ wrpac_test_wrp_id }}" + validate_certs: false + return_content: true + register: rp_entry + + - name: Assert RP List entry matches expected identity + ansible.builtin.assert: + that: + - rp_entry.json.wrp_id == wrpac_test_wrp_id + - rp_entry.json.legal_name == wrpac_test_organisation_name + - rp_entry.json.organization_identifier == wrpac_test_organisation_identifier + - rp_entry.json.country == wrpac_test_country + - rp_entry.json.status == "active" + + - name: "Test 01 passed" + ansible.builtin.debug: + msg: "Directories and RP List sanity OK" diff --git a/deployment/tests/test_02_phase1_acme.yml b/deployment/tests/test_02_phase1_acme.yml new file mode 100644 index 00000000..3cb9d4af --- /dev/null +++ b/deployment/tests/test_02_phase1_acme.yml @@ -0,0 +1,61 @@ +--- +# Test 02 — Phase 1 stock ACME issuance via acme.sh http-01. +# Local-only: the http-01 validator (Dogtag) must reach back to the client's :80 +# listener, so this won't work through a hairpinned public URL. + +- name: "Test 02: Phase 1 stock ACME http-01" + hosts: all + gather_facts: false + vars: + acme_dir: "https://{{ ca_fqdn }}:8443/acme/directory" + + tasks: + + - name: Ensure /root/.acme.sh exists (test setup ran) + ansible.builtin.stat: + path: /root/.acme.sh/acme.sh + register: acme_sh_present + + - name: Skip if acme.sh isn't installed (run playbooks/99-smoke-test.yml first) + ansible.builtin.meta: end_play + when: not acme_sh_present.stat.exists + + - name: Clear acme.sh's cached account (Dogtag's ACME state is in-memory and resets across pki-tomcatd restarts) + ansible.builtin.shell: | + rm -rf /root/.acme.sh/ca/* /root/.acme.sh/account.key 2>/dev/null || true + changed_when: false + + - name: Re-issue a stock ACME cert via standalone http-01 + ansible.builtin.command: > + /root/.acme.sh/acme.sh + --server {{ acme_dir }} + --issue + --standalone + --insecure + --keylength 2048 + -d {{ ca_fqdn }} + --force + register: acme_issue + changed_when: true + + - name: Assert issuance succeeded + ansible.builtin.assert: + that: + - acme_issue.rc == 0 + - "'Success' in acme_issue.stdout" + + - name: Inspect issued cert + ansible.builtin.command: > + openssl x509 -in /root/.acme.sh/{{ ca_fqdn }}/{{ ca_fqdn }}.cer -noout -subject -issuer -nameopt RFC2253 + register: stock_cert + changed_when: false + + - name: Assert stock cert has expected DNS Subject and our CA Issuer + ansible.builtin.assert: + that: + - "'CN=' + ca_fqdn in stock_cert.stdout" + - "'CA Signing Certificate' in stock_cert.stdout" + + - name: "Test 02 passed" + ansible.builtin.debug: + msg: "{{ stock_cert.stdout_lines }}" diff --git a/deployment/tests/test_03_phase2_direct.yml b/deployment/tests/test_03_phase2_direct.yml new file mode 100644 index 00000000..29ebc07e --- /dev/null +++ b/deployment/tests/test_03_phase2_direct.yml @@ -0,0 +1,67 @@ +--- +# Test 03 — Phase 2 direct CA enrolment with the wrpacCert profile. +# Local-only: needs the caadmin NSS DB on the box. + +- name: "Test 03: Phase 2 direct enrolment" + hosts: all + gather_facts: false + vars: + smoke_dir: /root/wrpac-smoke + nss_dir: "/root/wrpac-smoke/nssdb" + nss_password: Secret.123 + csr_file: "/root/wrpac-smoke/wrpac.csr" + cert_out: "/root/test-out/phase2-direct.pem" + admin_nickname: "PKI Administrator for lab" + + tasks: + + - name: Ensure the Phase 2 NSS DB is present + ansible.builtin.stat: + path: "{{ nss_dir }}/cert9.db" + register: nss_present + + - name: Skip if Phase 2 hasn't been deployed + ansible.builtin.meta: end_play + when: not nss_present.stat.exists + + - name: Ensure output dir + ansible.builtin.file: + path: /root/test-out + state: directory + owner: root + mode: "0700" + + - name: Submit a fresh CSR with the wrpacCert profile + ansible.builtin.command: > + pki -d {{ nss_dir }} -c {{ nss_password }} + -n "{{ admin_nickname }}" + -U https://{{ ca_fqdn }}:8443 + ca-cert-request-submit + --profile {{ wrpac_profile_id }} + --csr-file {{ csr_file }} + register: submit + changed_when: true + + - name: Assert auto-issue (agent submitter → complete) + ansible.builtin.assert: + that: + - "'Request Status: complete' in submit.stdout" + - "'Operation Result: success' in submit.stdout" + - "'Certificate ID:' in submit.stdout" + + - name: Capture Certificate ID + ansible.builtin.set_fact: + cert_id: "{{ submit.stdout | regex_search('Certificate ID:\\s*(\\S+)', '\\1') | first }}" + + - name: Export issued cert + ansible.builtin.command: > + pki -d {{ nss_dir }} -c {{ nss_password }} + -n "{{ admin_nickname }}" + -U https://{{ ca_fqdn }}:8443 + ca-cert-show {{ cert_id }} + --output {{ cert_out }} + changed_when: true + + - name: "Test 03 passed — cert at {{ cert_out }}" + ansible.builtin.debug: + msg: "Certificate ID {{ cert_id }} exported to {{ cert_out }}" diff --git a/deployment/tests/test_04_phase3_happy.yml b/deployment/tests/test_04_phase3_happy.yml new file mode 100644 index 00000000..9a1b036c --- /dev/null +++ b/deployment/tests/test_04_phase3_happy.yml @@ -0,0 +1,43 @@ +--- +# Test 04 — Phase 3 CS-06 §6 happy path via the Python ACME client. +# Targets either local or public URL via test_acme_directory / test_rp_list. + +- name: "Test 04: Phase 3 CS-06 happy path" + hosts: localhost + connection: local + gather_facts: false + vars: + test_acme_directory: "http://{{ ca_fqdn }}:{{ wrpac_facade_port }}/acme-eudi-wrpac/directory" + test_rp_list: "http://{{ ca_fqdn }}:{{ wrpac_rp_list_port }}" + test_python_bin: "{{ playbook_dir }}/../.venv/bin/python" + test_out_dir: "{{ playbook_dir }}/../test-out" + cert_out: "{{ test_out_dir }}/phase3-{{ lookup('pipe', 'date +%s') }}.pem" + + tasks: + + - name: Ensure output dir + ansible.builtin.file: + path: "{{ test_out_dir }}" + state: directory + mode: "0755" + + - name: Run the Phase 3 client + ansible.builtin.command: > + {{ test_python_bin }} -m wrpac_acme.client + --directory {{ test_acme_directory }} + --rp-list {{ test_rp_list }} + --wrp-id {{ wrpac_test_wrp_id }} + --legal-name "{{ wrpac_test_organisation_name }}" + --org-id {{ wrpac_test_organisation_identifier }} + --country {{ wrpac_test_country }} + --friendly-name "{{ wrpac_test_friendly_name }}" + --out-cert {{ cert_out }} + register: client_run + + - name: Tail of client output + ansible.builtin.debug: + msg: "{{ client_run.stdout_lines[-12:] }}" + + - name: Set cert_out fact for downstream tests + ansible.builtin.set_fact: + phase3_cert: "{{ cert_out }}" diff --git a/deployment/tests/test_05_phase3_negative.yml b/deployment/tests/test_05_phase3_negative.yml new file mode 100644 index 00000000..bf93768a --- /dev/null +++ b/deployment/tests/test_05_phase3_negative.yml @@ -0,0 +1,31 @@ +--- +# Test 05 — CS-06 §7.2 negative-test battery against the Phase 3 façade. + +- name: "Test 05: Phase 3 negative tests" + hosts: localhost + connection: local + gather_facts: false + vars: + test_acme_directory: "http://{{ ca_fqdn }}:{{ wrpac_facade_port }}/acme-eudi-wrpac/directory" + test_rp_list: "http://{{ ca_fqdn }}:{{ wrpac_rp_list_port }}" + test_python_bin: "{{ playbook_dir }}/../.venv/bin/python" + + tasks: + - name: Run the negative-test battery + ansible.builtin.command: > + {{ test_python_bin }} -m wrpac_acme.negative_tests + --directory {{ test_acme_directory }} + --rp-list {{ test_rp_list }} + register: neg + changed_when: false + + - name: Show negative-test report + ansible.builtin.debug: + msg: "{{ neg.stdout_lines }}" + + - name: Assert all negative tests passed + ansible.builtin.assert: + that: + - "'failed' in neg.stdout" + - "'0 failed' in neg.stdout" + fail_msg: "Negative-test battery reports failures — see output above" diff --git a/deployment/tests/test_06_cert_profile.yml b/deployment/tests/test_06_cert_profile.yml new file mode 100644 index 00000000..109f8123 --- /dev/null +++ b/deployment/tests/test_06_cert_profile.yml @@ -0,0 +1,120 @@ +--- +# Test 06 — CS-06 §7.4 attribute assertions on a freshly-issued WRPAC. +# +# Issues one cert via the Phase 3 client (against either local or public URL) +# and asserts every §7.4 attribute. This is the most useful single test for +# verifying the cert profile end-to-end. + +- name: "Test 06: CS-06 §7.4 attributes" + hosts: localhost + connection: local + gather_facts: false + vars: + test_acme_directory: "http://{{ ca_fqdn }}:{{ wrpac_facade_port }}/acme-eudi-wrpac/directory" + test_rp_list: "http://{{ ca_fqdn }}:{{ wrpac_rp_list_port }}" + test_python_bin: "{{ playbook_dir }}/../.venv/bin/python" + test_out_dir: "{{ playbook_dir }}/../test-out" + cert_path: "{{ test_out_dir }}/profile-check.pem" + ca_root_path: "{{ test_out_dir }}/dogtag-ca-root.pem" + + tasks: + + - name: Ensure output dir + ansible.builtin.file: + path: "{{ test_out_dir }}" + state: directory + mode: "0755" + + - name: Fetch the Dogtag CA root via SSH (needed for chain verification) + ansible.builtin.shell: | + ssh cbadmin@{{ ca_fqdn }} 'sudo cat /root/wrpac-smoke/ca_signing.crt' > {{ ca_root_path }} + test -s {{ ca_root_path }} + args: + creates: "{{ ca_root_path }}" + + - name: Issue a fresh cert via the Phase 3 client + ansible.builtin.command: > + {{ test_python_bin }} -m wrpac_acme.client + --directory {{ test_acme_directory }} + --rp-list {{ test_rp_list }} + --wrp-id {{ wrpac_test_wrp_id }} + --legal-name "{{ wrpac_test_organisation_name }}" + --org-id {{ wrpac_test_organisation_identifier }} + --country {{ wrpac_test_country }} + --friendly-name "CS-06 §7.4 check" + --out-cert {{ cert_path }} + changed_when: true + + - name: Dump cert (text + extensions) + ansible.builtin.command: > + openssl x509 -in {{ cert_path }} -noout -text -nameopt RFC2253 + register: cert_text + changed_when: false + + - name: "§7.4 #1 — X.509 v3" + ansible.builtin.assert: + that: "'Version: 3' in cert_text.stdout" + + - name: "§7.4 #3 — legal name in Subject (O=)" + ansible.builtin.assert: + that: "'O=' + wrpac_test_organisation_name in cert_text.stdout" + + - name: "§7.4 #5 — unique WRP identifier (organizationIdentifier / 2.5.4.97)" + ansible.builtin.assert: + that: + - "'organizationIdentifier=' + wrpac_test_organisation_identifier in cert_text.stdout or '2.5.4.97=' + wrpac_test_organisation_identifier in cert_text.stdout" + + - name: "§7.4 #6 — certificate policy OID present" + ansible.builtin.assert: + that: wrpac_policy_oid_legal in cert_text.stdout + + - name: "§7.4 #7 — certification path info URI (AIA)" + ansible.builtin.assert: + that: "'Authority Information Access' in cert_text.stdout" + + - name: "§7.4 #8 — SAN with DNS or URI" + ansible.builtin.assert: + that: + - "'Subject Alternative Name' in cert_text.stdout" + - "'DNS:' in cert_text.stdout or 'URI:' in cert_text.stdout" + + - name: "§7.4 #12 — SHA-256+ signature, RSA-3072+ or ECDSA P-256/384" + ansible.builtin.assert: + that: + - "'sha256' in cert_text.stdout or 'sha384' in cert_text.stdout or 'sha512' in cert_text.stdout" + - "'Public-Key: (3072 bit)' in cert_text.stdout or 'Public-Key: (4096 bit)' in cert_text.stdout or 'NIST CURVE: P-256' in cert_text.stdout or 'NIST CURVE: P-384' in cert_text.stdout" + + - name: "§7.4 #13 — 1-year validity" + ansible.builtin.shell: | + set -e -o pipefail + not_before=$(openssl x509 -in {{ cert_path }} -noout -startdate | cut -d= -f2) + not_after=$(openssl x509 -in {{ cert_path }} -noout -enddate | cut -d= -f2) + nb_epoch=$(date -d "$not_before" +%s) + na_epoch=$(date -d "$not_after" +%s) + days=$(( (na_epoch - nb_epoch) / 86400 )) + echo "validity days: $days" + test "$days" -ge 360 && test "$days" -le 366 + args: + executable: /bin/bash + register: validity_check + changed_when: false + + - name: "Show validity check" + ansible.builtin.debug: + msg: "{{ validity_check.stdout }}" + + - name: "Chain verifies against the Dogtag root" + ansible.builtin.command: > + openssl verify -CAfile {{ ca_root_path }} {{ cert_path }} + register: chain + changed_when: false + + - name: "Assert chain OK" + ansible.builtin.assert: + that: "'OK' in chain.stdout" + + - name: "Test 06 passed — cert at {{ cert_path }} meets CS-06 §7.4" + ansible.builtin.debug: + msg: + - "{{ chain.stdout }}" + - "{{ validity_check.stdout }}" diff --git a/deployment/tests/test_07_persistence.yml b/deployment/tests/test_07_persistence.yml new file mode 100644 index 00000000..38467482 --- /dev/null +++ b/deployment/tests/test_07_persistence.yml @@ -0,0 +1,86 @@ +--- +# Test 07 — ACME state survives pki-tomcatd restart. +# +# 1. Clear acme.sh's local account cache so we start fresh +# 2. Register an account by issuing a cert (acme.sh) +# 3. Restart pki-tomcatd +# 4. Issue another cert with the SAME local account state +# +# With the in-memory backend, step 4 fails (accountDoesNotExist). +# With the ds backend, step 4 succeeds. + +- name: "Test 07: persistence — ACME state survives pki-tomcatd restart" + hosts: all + gather_facts: false + vars: + acme_dir: "https://{{ ca_fqdn }}:8443/acme/directory" + + tasks: + + - name: Skip if acme.sh isn't installed + ansible.builtin.stat: + path: /root/.acme.sh/acme.sh + register: acme_sh + + - name: Skip play + ansible.builtin.meta: end_play + when: not acme_sh.stat.exists + + - name: Clear acme.sh state for a clean baseline + ansible.builtin.shell: | + rm -rf /root/.acme.sh/ca/* /root/.acme.sh/account.key 2>/dev/null || true + changed_when: false + + - name: Issue baseline cert (registers account) + ansible.builtin.command: > + /root/.acme.sh/acme.sh + --server {{ acme_dir }} + --issue --standalone --insecure --keylength 2048 + -d {{ ca_fqdn }} --force + register: baseline_issue + changed_when: true + + - name: Assert baseline issuance succeeded + ansible.builtin.assert: + that: + - baseline_issue.rc == 0 + - "'Success' in baseline_issue.stdout" + + - name: Restart pki-tomcatd to test state persistence + ansible.builtin.systemd: + name: "pki-tomcatd@{{ pki_instance_name }}" + state: restarted + + - name: Wait for ACME directory after restart + ansible.builtin.uri: + url: "{{ acme_dir }}" + validate_certs: false + register: post_restart_dir + retries: 12 + delay: 5 + until: post_restart_dir.status == 200 + + - name: Re-issue cert with the SAME account (should work iff state persisted) + ansible.builtin.command: > + /root/.acme.sh/acme.sh + --server {{ acme_dir }} + --issue --standalone --insecure --keylength 2048 + -d {{ ca_fqdn }} --force + register: post_restart_issue + changed_when: true + + - name: Assert post-restart issuance succeeded (proves persistence) + ansible.builtin.assert: + that: + - post_restart_issue.rc == 0 + - "'Success' in post_restart_issue.stdout" + - "'accountDoesNotExist' not in post_restart_issue.stderr" + - "'accountDoesNotExist' not in post_restart_issue.stdout" + fail_msg: | + ACME state did not survive pki-tomcatd restart. + stderr: {{ post_restart_issue.stderr | default('(empty)') }} + stdout tail: {{ post_restart_issue.stdout_lines[-5:] | default([]) }} + + - name: "Test 07 passed — account survived restart with backend {{ acme_database_backend }}" + ansible.builtin.debug: + msg: "Account persistence verified" diff --git a/deployment/tests/test_08_facade_persistence.yml b/deployment/tests/test_08_facade_persistence.yml new file mode 100644 index 00000000..dd47ad78 --- /dev/null +++ b/deployment/tests/test_08_facade_persistence.yml @@ -0,0 +1,111 @@ +--- +# Test 08 — CS-06 façade state survives wrpac-acme-facade restart. +# +# 1. Issue a cert via Phase 3 client → creates account + order + cert rows +# 2. Snapshot SQLite row counts before restart +# 3. Restart wrpac-acme-facade +# 4. Snapshot SQLite row counts after restart +# 5. Assert: counts > 0 and unchanged + +- name: "Test 08: façade persistence — state survives wrpac-acme-facade restart" + hosts: all + gather_facts: false + vars: + db: /var/lib/wrpac-acme/facade.sqlite3 + rp_db: /var/lib/wrpac-acme/rp_list.sqlite3 + acme_dir: "http://{{ ca_fqdn }}:{{ wrpac_facade_port }}/acme-eudi-wrpac/directory" + rp_list_url: "http://{{ ca_fqdn }}:{{ wrpac_rp_list_port }}" + + tasks: + + - name: Ensure sqlite3 CLI is available + ansible.builtin.dnf: + name: sqlite + state: present + + - name: Issue a fresh cert via Phase 3 client (populates SQLite) + ansible.builtin.command: > + {{ wrpac_facade_root }}/.venv/bin/python -m wrpac_acme.client + --directory {{ acme_dir }} + --rp-list {{ rp_list_url }} + --wrp-id {{ wrpac_test_wrp_id }} + --legal-name "{{ wrpac_test_organisation_name }}" + --org-id {{ wrpac_test_organisation_identifier }} + --country {{ wrpac_test_country }} + --friendly-name "Test 08 persistence" + --out-cert /tmp/test_08.pem + changed_when: true + + - name: Snapshot row counts before restart + ansible.builtin.shell: | + set -e + echo "accounts=$(sqlite3 {{ db }} 'SELECT COUNT(*) FROM accounts')" + echo "orders=$(sqlite3 {{ db }} 'SELECT COUNT(*) FROM orders')" + echo "authzs=$(sqlite3 {{ db }} 'SELECT COUNT(*) FROM authzs')" + echo "challenges=$(sqlite3 {{ db }} 'SELECT COUNT(*) FROM challenges')" + echo "certs=$(sqlite3 {{ db }} 'SELECT COUNT(*) FROM certs')" + echo "rp_entries=$(sqlite3 {{ rp_db }} 'SELECT COUNT(*) FROM entries')" + args: + executable: /bin/bash + register: before + changed_when: false + + - name: Show pre-restart snapshot + ansible.builtin.debug: + msg: "{{ before.stdout_lines }}" + + - name: Restart wrpac-acme-facade + wrpac-rp-list + ansible.builtin.systemd: + name: "{{ item }}" + state: restarted + loop: + - wrpac-rp-list + - wrpac-acme-facade + + - name: Wait for façade to come back + ansible.builtin.wait_for: + host: 127.0.0.1 + port: "{{ wrpac_facade_port }}" + timeout: 30 + + - name: Snapshot row counts after restart + ansible.builtin.shell: | + set -e + echo "accounts=$(sqlite3 {{ db }} 'SELECT COUNT(*) FROM accounts')" + echo "orders=$(sqlite3 {{ db }} 'SELECT COUNT(*) FROM orders')" + echo "authzs=$(sqlite3 {{ db }} 'SELECT COUNT(*) FROM authzs')" + echo "challenges=$(sqlite3 {{ db }} 'SELECT COUNT(*) FROM challenges')" + echo "certs=$(sqlite3 {{ db }} 'SELECT COUNT(*) FROM certs')" + echo "rp_entries=$(sqlite3 {{ rp_db }} 'SELECT COUNT(*) FROM entries')" + args: + executable: /bin/bash + register: after + changed_when: false + + - name: Show post-restart snapshot + ansible.builtin.debug: + msg: "{{ after.stdout_lines }}" + + - name: Assert row counts non-zero and unchanged + ansible.builtin.assert: + that: + - after.stdout == before.stdout + - "'accounts=0' not in after.stdout" + - "'orders=0' not in after.stdout" + - "'certs=0' not in after.stdout" + - "'rp_entries=0' not in after.stdout" + fail_msg: | + State did NOT survive restart. + before: + {{ before.stdout }} + after: + {{ after.stdout }} + + - name: Sanity-check we can still GET the directory (façade is healthy) + ansible.builtin.uri: + url: "{{ acme_dir }}" + register: post_restart_dir + + - name: "Test 08 passed — accounts/orders/certs survived restart" + ansible.builtin.debug: + msg: "Façade persistence verified." diff --git a/deployment/tests/test_all.yml b/deployment/tests/test_all.yml new file mode 100644 index 00000000..8e506ecf --- /dev/null +++ b/deployment/tests/test_all.yml @@ -0,0 +1,12 @@ +--- +# Master test playbook — runs every test in order. Pass URL overrides via -e +# to target the public deployment instead of the local one. See tests/README.md. + +- import_playbook: test_01_directory.yml +- import_playbook: test_02_phase1_acme.yml +- import_playbook: test_03_phase2_direct.yml +- import_playbook: test_04_phase3_happy.yml +- import_playbook: test_05_phase3_negative.yml +- import_playbook: test_06_cert_profile.yml +- import_playbook: test_07_persistence.yml +- import_playbook: test_08_facade_persistence.yml From 76241ca60d79efe26147c5fb7ff6c44e53fa51a9 Mon Sep 17 00:00:00 2001 From: Jilles van Oossanen Date: Mon, 15 Jun 2026 14:09:45 +0200 Subject: [PATCH 05/14] Remove CLAUDE.md from PR and gitignore it CLAUDE.md is a per-developer local file; contributors may keep their own uncommitted version, so it should not be tracked in the repo. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 ++- CLAUDE.md | 50 -------------------------------------------------- 2 files changed, 2 insertions(+), 51 deletions(-) delete mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore index 293368da..ecd7542c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .DS_Store -*.bak \ No newline at end of file +*.bak +CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index e238bb85..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,50 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Repository purpose - -This is a **documentation and governance** repository for the WE BUILD consortium's WP4 Architecture Group — it contains no application code. Contributions are Markdown documents that flow through a defined governance process and are compiled into a published deliverable (D4.1). - -Three artifact types, each with its own lifecycle and template: - -- **ADRs** ([adr/](adr/)) — lightweight Architecture Decision Records, one per software decision affecting interoperability. Template: [adr/_template.md](adr/_template.md). New ADRs must be added to the numbered list in [adr/README.md](adr/README.md) between the `` / `` markers — that list is the source of truth the blueprint build reads from. -- **Conformance Specifications (WBCS / CSs)** ([conformance-specs/](conformance-specs/)) — normative implementation requirements translating ADR decisions into testable specs (RFC 2119 language). Template: [conformance-specs/_template.md](conformance-specs/_template.md). Approved CSs go in the "Approved WBCSs" table in [conformance-specs/README.md](conformance-specs/README.md); the build reads CS filenames from any `.md)` link in that README. -- **Blueprint (D4.1)** ([blueprint/](blueprint/)) — the consolidated deliverable. Authored as numbered chapter files (`01-executive-summary.md` … `09-roadmap.md`) plus `appendix-*.md` files; the build assembles these together with the ADR and CS appendices into a single HTML and PDF. - -Drafts that aren't yet mature live in [webuild-drafts/](webuild-drafts/). - -## Building the Blueprint - -The build is a multi-stage shell pipeline in [blueprint/build.sh](blueprint/build.sh) that assembles every chapter + every ADR + every CS into one Markdown file, converts to AsciiDoc via `kramdoc`, and renders HTML and PDF via `asciidoctor` / `asciidoctor-pdf`. It is run from inside `blueprint/`: - -```sh -cd blueprint -./build.sh # incremental local build (assumes deps already installed) -./build.sh --github-action # also runs setup() to install Ruby, Python, Mermaid CLI, Chrome -``` - -Outputs land in `build_outputs_folder/blueprint/` (`blueprint.html`, `blueprint.pdf`, plus copied images). The `.venv/` Python environment is created on first `--github-action` run and reactivated on subsequent runs. - -CI ([.github/workflows/build-blueprint.yml](.github/workflows/build-blueprint.yml)) runs the build on push to `main`, `asciidoc`, or `blueprint-preview` and deploys to GitHub Pages. Published at: -- https://webuild-consortium.github.io/wp4-architecture/blueprint/blueprint.html -- https://webuild-consortium.github.io/wp4-architecture/blueprint/blueprint.pdf - -Dependencies (only needed when building locally): -- Ruby gems: `kramdown-asciidoc`, `asciidoctor-diagram`, `asciidoctor-pdf` (see [blueprint/Gemfile](blueprint/Gemfile)) -- Python (in `blueprint/.venv`): `enumerate-markdown`, `mistune==2.0.5` (see [requirements.txt](requirements.txt)) — provides the `markdown-enum` command used to number sections -- Node: `@mermaid-js/mermaid-cli` plus a pinned Chrome (`chrome@145.0.7632.46`) for diagram rendering - -## Things that will trip you up in the build - -- **The QTSP appendix is fetched from a sibling repo at build time** — `collect_qtsp_appendix` in [blueprint/build.sh](blueprint/build.sh) downloads `webuild-consortium/wp4-qtsp-group` as a zip and stitches READMEs together into `appendix-qtsp.md`. Don't commit `appendix-qtsp.md`, `qtsp-main.zip`, or the unpacked `wp4-qtsp-group-main/` directory. -- **ADR and CS appendices are auto-included from the `README.md` files in those directories.** To add an ADR or CS to the blueprint, edit the relevant README — don't touch `build.sh`. The build extracts `.md)` link targets from `adr/README.md` and `conformance-specs/README.md`. -- **Header levels get shifted** during inclusion (`indent_headers` is applied twice to each ADR/CS so a top-level `#` in the source becomes `###` in the blueprint). Author ADR/CS files as standalone documents with normal `#` headings; do not pre-shift. -- **The CS `_template.md` is escaped specially** — `<` and `>` are replaced with `<` / `>` before inclusion so AsciiDoc doesn't interpret placeholder angle brackets as tags. This is handled in `build.sh`; the template itself uses literal `<` / `>`. -- **Table column widths are patched post-conversion** via `table_width` in `build.sh`, keyed on the header text of specific tables in `main.adoc`. If you change those header rows in the source Markdown, also update the matching `fgrep` strings in `build.sh`. - -## Governance - -- All files are reviewed by `@webuild-consortium/blueprint-coordination-group` ([.github/CODEOWNERS](.github/CODEOWNERS)). -- Workflow: open an issue → discuss → PR → BCG review and merge. The ADR process diagram lives in [adr/README.md](adr/README.md). -- Once an ADR is merged it is the consortium's decision; dissenting views are captured in the **Advice** section of the ADR rather than blocking the merge. From 6d42d1d8042d2e3db4af4c356a78c98022a5478a Mon Sep 17 00:00:00 2001 From: Jilles van Oossanen Date: Mon, 15 Jun 2026 15:38:18 +0200 Subject: [PATCH 06/14] CS-06: replace POA with EBWOID authentication; v0.3 Authenticate via the organisation's EBWOID attestation (rb-ebwoid) instead of a Power of Attorney. EBWOID.id is the same EUID-style identifier format as wrp-id, so the WRP binding becomes a direct, testable match: the ACME Server MUST verify EBWOID.id == wrp-id and EBWOID.name == the RP List WRP legal name (new normative MUST in 7.2 item 9, with matching conformance bullet in 9). Authorisation now derives from control of the organisation's EBW; no separate POA or representative binding. Bumps version to 0.3 and adds author. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ty-access-and-registration-certificates.md | 66 ++++++++++--------- 1 file changed, 34 insertions(+), 32 deletions(-) diff --git a/conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md b/conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md index e99c2f48..59d66a6a 100644 --- a/conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md +++ b/conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md @@ -1,7 +1,8 @@ # WE BUILD - Conformance Specification: Issuance of Relying Party Access and Registration Certificates -Version 0.2 / First proposal -Date: 24 April 2026 +Version 0.3 +Date: 15 June 2026 +Author: Jilles van Oossanen ## Table of Contents @@ -34,7 +35,7 @@ Date: 24 April 2026 - [7.9 IANA Considerations](#79-iana-considerations) - [8. Interface Definitions](#8-interface-definitions) - [8.1 ACME Directory](#81-acme-directory) - - [8.2 Account Management with EBW/POA](#82-account-management-with-ebwpoa) + - [8.2 Account Management with EBW/EBWOID](#82-account-management-with-ebwebwoid) - [8.3 Order Lifecycle](#83-order-lifecycle) - [8.4 Authorization and Challenge](#84-authorization-and-challenge) - [8.5 Finalize and Certificate](#85-finalize-and-certificate) @@ -58,7 +59,7 @@ It profiles: This specification positions the ACME protocol as a **technical implementation** of the integrated issuance model described in ETSI TS 119 475 v1.2.1 Annex D, Use Case 1 [4], as adopted by the WE BUILD Blueprint [5]. Section 9 provides an explicit mapping between the WE BUILD Blueprint issuance workflow and the ACME protocol operations. -This specification focuses **only on direct issuance** of WRPACs to registered Wallet-Relying Parties. The ACME flow is extended with EBW-based authentication and POA verification to align with the Blueprint's requirements for user authentication via European Business Wallets. This document is used to build the WE BUILD Interoperability Test Bed Plus (ITB+) [6]. +This specification focuses **only on direct issuance** of WRPACs to registered Wallet-Relying Parties. The ACME flow is extended with EBW-based authentication and EBWOID verification to align with the Blueprint's requirements for user authentication via European Business Wallets. This document is used to build the WE BUILD Interoperability Test Bed Plus (ITB+) [6]. > [!IMPORTANT] > CS-RPAC_01: This specification is intended for **interoperability testing only**, not for production deployment. Its purpose is to validate the technical feasibility of ACME-based WRPAC issuance and to establish interoperability between independent implementations. Production deployments will require additional security hardening, policy alignment, and conformity assessment beyond the scope of this document. @@ -102,10 +103,9 @@ The following terminology applies throughout this specification: * **EUDIW** — European Digital Identity Wallet. * **RP** — Relying Party; in this specification usually **WRP** (Wallet-Relying Party). * **RA / CA / TSP** — Registration Authority, Certificate Authority, Trust Service Provider. -* **POA** — Power of Attorney. * **EAA** — Electronic Attestation of Attributes. * **PID** — Person Identification Data. -* **EBWOID** — European Business Wallet Organisational Identification Data. +* **EBWOID** — European Business Wallet Organisational Identification Data, as defined in the WE BUILD EBWOID Attestation Rulebook [20]. Carries the organisation's unique identifier (`id`) and official name (`name`). # 4. Roles and Components @@ -114,15 +114,15 @@ This specification uses the following roles, mapped to both the ACME protocol an | ACME Role | Blueprint Role | Description | |---|---|---| | **ACME Client** | **User + EBW** | The RP representative using an European Business Wallet. The EBW plays two roles in this specification: (i) host or invocation environment for the ACME Client software, and (ii) user-authentication authority for EBW-based External Account Binding per RFC 8555 §7.3.4 [1]. | -| **ACME Server** | **RA + CA** | The combined Registration Authority and Certificate Authority. The RA function handles identity verification, POA validation, and RP list checks. The CA function handles certificate generation and signing. These MAY be separate systems behind a single ACME endpoint. | +| **ACME Server** | **RA + CA** | The combined Registration Authority and Certificate Authority. The RA function handles identity verification, EBWOID validation, and RP list checks. The CA function handles certificate generation and signing. These MAY be separate systems behind a single ACME endpoint. | | — | **Mock Registrar (RA/TSP)** | In the WE BUILD pilot, participating TSPs acting as mock Registrars maintain the Lists of authorised RPs. This role is functionally equivalent to the Member State Registrar in the production eIDAS ecosystem. In production, this function is performed by the national Registrar of the Member State concerned per the eIDAS framework. | | **Wallet Unit** | — | The EUDI/Business Wallet that verifies WRPACs during RP authentication. Not involved in issuance. | Detailed role descriptions: -* **User (RP Representative):** A natural person authorized to act on behalf of the Wallet-Relying Party. Authenticates via an EBW and presents a Power of Attorney (POA) attestation as an EAA. -* **European Business Wallet (EBW):** The wallet application used by the RP representative to authenticate to the RA and to retrieve issued certificates. In the ACME flow, the EBW acts as the ACME Client or provides the authentication layer for the ACME Client. -* **Registration Authority (RA):** The TSP component that verifies the user's identity and POA, requests additional attributes for the RPRC, and checks the RP's presence in the authorised RP lists. Implemented as part of the ACME Server. +* **User (RP Representative):** A natural person operating on behalf of the Wallet-Relying Party via the organisation's EBW. Authenticates via the EBW, which presents the organisation's EBWOID attestation (an EAA). Authorisation to obtain a certificate for the WRP follows from control of the organisation's EBW; this profile does not require a separate Power of Attorney or representative attestation. +* **European Business Wallet (EBW):** The wallet application used to authenticate to the RA and to retrieve issued certificates. In the ACME flow, the EBW acts as the ACME Client or provides the authentication layer for the ACME Client. +* **Registration Authority (RA):** The TSP component that verifies the organisation's identity by validating the EBWOID, requests additional attributes for the RPRC, and checks the RP's presence in the authorised RP lists. Implemented as part of the ACME Server. * **Certificate Authority (CA):** The TSP component that generates and signs WRPAC and WRPRC certificates. Implemented as the ACME Server's certificate issuance backend. * **Mock Registrar:** In the WE BUILD pilot, participating TSPs acting as mock Registrars establish and maintain the Lists of authorized Relying Parties. These lists serve as the functional equivalent of the national register of wallet-relying parties per the Blueprint [5] MVP governance model. The ACME Server checks these lists during authorization. During ITB+ conformance testing, the WE BUILD RP List is served by the mock Registrar; in production, by the national Registrar of the Member State concerned. @@ -134,8 +134,8 @@ The key adaptations are: * **Identifier type `wrp-id`**: replaces the `dns` identifier. The value is the WRP's unique identifier as it appears in the WE BUILD RP Lists. For the `wrp-id` identifier type, the only defined challenge is `registrar-api-01`; the standard ACME challenges `http-01` and `dns-01` do not apply to `wrp-id`. * **Challenge type `registrar-api-01`**: the ACME Server verifies the WRP's presence in the WE BUILD RP Lists and confirms a token link between the ACME account and the RP entry. The RP List entry supports multiple concurrent challenge tokens to enable multi-instance issuance (see §5.2). -* **EBW-based External Account Binding (EAB)**: REQUIRED for account creation. The RP representative authenticates via their EBW and presents a POA (EAA). The EAB binds the ACME account to the verified EBW identity and POA. -* **RA-integrated ACME Server**: the ACME Server combines RA and CA functions. The RA function validates identity, POA, and RP list membership. The CA function generates certificates. +* **EBW-based External Account Binding (EAB)**: REQUIRED for account creation. The EBW presents the organisation's EBWOID (EAA). The EAB binds the ACME account to the verified EBW organisational identity (EBWOID). +* **RA-integrated ACME Server**: the ACME Server combines RA and CA functions. The RA function validates the EBWOID, the organisation's identity, and RP list membership. The CA function generates certificates. * **Multi-instance issuance**: a single WRP (`wrp-id`) MAY obtain multiple WRPACs, one per Relying Party Instance. Each instance is identified by an optional `instanceId` in the order (see §5.2). * **Co-issuance of WRPAC + WRPRC**: the ACME order MAY result in both a WRPAC and a WRPRC being issued together, as specified in the Blueprint and in CIR (EU) 2025/848 Annex V [2]. * **Certificate profile**: the issued certificate is an X.509 v3 WRPAC conforming to CIR 2025/848 Annex IV and ETSI TS 119 411-8 v1.1.1 [3], with attribute content as specified in ETSI TS 119 475 v1.2.1 clause 5 [4]. @@ -212,7 +212,7 @@ When `instanceId` is omitted, the ACME Server treats the order as targeting a si 5. The ACME Server checks the RP List: * WRP is present and authorized * `acme-challenge` attribute (for the relevant `instanceId`) matches the expected `key-authorization` - * Identity data is consistent with the account and POA + * The EBWOID bound to the account corresponds to the WRP: `EBWOID.id` equals the order's `wrp-id` and `EBWOID.name` matches the entry's *WRP legal name* (§7.2 item 9) 6. Authorization transitions to `valid` on success. **Challenge object:** @@ -235,9 +235,9 @@ This profile extends ACME External Account Binding (RFC 8555 §7.3.4 [1]) with E **Mechanism:** -1. The RP representative authenticates to the RA using their EBW, presenting PID/EBWOID and a POA attestation (EAA). -2. The RA validates the POA and verifies the representative's authority. -3. The RA issues EAB credentials (Key ID + HMAC Key) bound to the verified identity and POA. +1. The EBW authenticates to the RA, presenting the organisation's EBWOID attestation (EAA). +2. The RA validates the EBWOID and verifies the organisation's identity. +3. The RA issues EAB credentials (Key ID + HMAC Key) bound to the verified organisational identity (EBWOID). 4. The ACME Client uses these EAB credentials during `newAccount`. **EAB in newAccount:** @@ -257,7 +257,7 @@ Per RFC 8555 §7.3.4 [1], the `externalAccountBinding` value is a flattened JWS ``` > [!NOTE] -> CS-RPAC_05: **[MVP]** For initial interoperability testing, EBW authentication MAY be simulated. EAB credentials are pre-provisioned by the RA/TSP after out-of-band identity verification. **[MVP+]** Future iterations MUST implement the full EBW-based OID4VP authentication flow for EAB provisioning, including presentation of PID/EBWOID and POA (EAA) via OID4VP. Implementations declaring MVP+ conformance MUST support the full OID4VP-based EBW authentication flow. +> CS-RPAC_05: **[MVP]** For initial interoperability testing, EBW authentication MAY be simulated. EAB credentials are pre-provisioned by the RA/TSP after out-of-band identity verification. **[MVP+]** Future iterations MUST implement the full EBW-based OID4VP authentication flow for EAB provisioning, including presentation of the organisation's EBWOID (EAA) via OID4VP. Implementations declaring MVP+ conformance MUST support the full OID4VP-based EBW authentication flow. # 6. High-level Flows @@ -270,12 +270,12 @@ Per RFC 8555 §7.3.4 [1], the `externalAccountBinding` value is a flattened JWS **Pre-ACME phase (Blueprint steps 1-3):** -1. The RP representative connects to the TSP's RA portal/service. -2. **[MVP+]** The RA initiates an OID4VP request to the user's EBW, requesting PID/EBWOID and POA (EAA). **[MVP]** This step MAY be performed out-of-band. -3. The EBW presents the requested credentials. -4. The RA validates the POA and verifies the representative's authority. +1. The RP representative connects to the TSP's RA portal/service via the organisation's EBW. +2. **[MVP+]** The RA initiates an OID4VP request to the EBW, requesting the organisation's EBWOID (EAA). **[MVP]** This step MAY be performed out-of-band. +3. The EBW presents the EBWOID. +4. The RA validates the EBWOID and verifies the organisation's identity. 5. Optionally (Blueprint step 4): the RA requests additional attributes for WRPRC production. -6. The RA issues EAB credentials to the user. +6. The RA issues EAB credentials to the EBW. **ACME phase:** @@ -342,11 +342,11 @@ The ACME Server **MUST**: 2. Require EAB for all accounts (RFC 8555 §7.3.4 [1]). 3. Support `wrp-id` identifiers and `registrar-api-01` challenges. 4. Verify WRP presence in the WE BUILD RP Lists during challenge validation (Blueprint step 5). -5. Reject authorization if the WRP is not in the list or identity data is inconsistent. +5. Reject authorization if the WRP is not in the list, or if the EBWOID bound to the account does not correspond to the requested `wrp-id` (see item 9). 6. Issue WRPACs exclusively to authorized WRPs. 7. Support the order state transitions defined in RFC 8555 §7.1.6 "Status Changes" [1]. 8. Support `revokeCert`. -9. Implement the RA function: POA validation (Blueprint steps 1-3) and RP List check (step 5). +9. Implement the RA function: EBWOID validation (Blueprint steps 1-3) and RP List check (step 5). As part of EBWOID validation, the ACME Server MUST verify that the organisation identified by the validated EBWOID corresponds to the order's `wrp-id`: specifically that `EBWOID.id` equals the `wrp-id` value and `EBWOID.name` matches the *WRP legal name* of the corresponding RP List entry (§7.8). The ACME Server MUST reject the order or authorization if they do not correspond. 10. Support multi-instance issuance: accept an optional `instanceId` in orders, verify its uniqueness among active WRPACs for the same `wrp-id`, and issue separate WRPACs per instance (§5.2). 11. Enforce that each Relying Party Instance receives a distinct WRPAC bound to a distinct key pair (see §7.3 item 4). 12. Upon successful replacement of a single-instance WRPAC (new order without `instanceId` replacing an existing single-instance WRPAC), MUST initiate revocation of the superseded certificate (see §5.2). @@ -494,11 +494,11 @@ This specification defines extensions to the ACME protocol that fall within IANA > [!NOTE] > CS-RPAC_10: `rprcCoIssuanceSupported` and `multiInstanceIssuanceSupported` are WE BUILD extensions to the ACME directory metadata. The former indicates the server can co-issue WRPAC and WRPRC; the latter indicates support for per-instance issuance per the ARF v2.8 Relying Party Instance model [15]. Registration considerations for these extensions are described in §7.9. -## 8.2 Account Management with EBW/POA +## 8.2 Account Management with EBW/EBWOID * **Method**: `POST` (JWS-signed) -**Pre-condition**: EBW authentication + POA validation completed; EAB credentials received. +**Pre-condition**: EBW authentication + EBWOID validation completed; EAB credentials received. ```json { @@ -602,13 +602,13 @@ An implementation **conforms as an ACME Server (TSP: RA + CA)** if it: 1. Implements ACME per RFC 8555 [1] with the extensions in §5. 2. Publishes an ACME directory per §8.1. -3. Requires EAB with EBW/POA verification (§5.4). +3. Requires EAB with EBW/EBWOID verification (§5.4). 4. Supports `wrp-id` identifiers and `registrar-api-01` challenges. 5. Verifies WRP presence in WE BUILD RP Lists (§7.8). 6. Issues certificates per §7.4, including compliance with ETSI TS 119 411-8 v1.1.1 [3] and the attribute mapping in ETSI TS 119 475 v1.2.1 clause 5 [4]. 7. Logs to CT logs (§7.5). 8. Supports revocation (§7.6). -9. Implements RA function: POA validation + RP List checks (§7.2). +9. Implements RA function: EBWOID validation, including the EBWOID-to-`wrp-id` match (`EBWOID.id` = `wrp-id`, `EBWOID.name` = *WRP legal name*) per §7.2 item 9, plus RP List checks (§7.2). 10. Supports multi-instance issuance per §5.2, §7.2, and §8.3. 11. Implements interfaces per §8. @@ -631,7 +631,7 @@ An implementation **conforms as a test environment** if it: An **MVP+ conformant** implementation additionally: 1. Implements the full OID4VP-based EBW authentication flow for EAB provisioning (§5.4, §6.2). -2. Accepts PID/EBWOID and POA (EAA) as presented via the EBW during the pre-ACME authentication phase. +2. Accepts the organisation's EBWOID (EAA) as presented via the EBW during the pre-ACME authentication phase. Profiles for specific WE BUILD credential types MUST NOT relax these requirements. @@ -641,7 +641,7 @@ Profiles for specific WE BUILD credential types MUST NOT relax these requirement |---|---|---|---| | **1** | User authenticates to RA using EBW | EBW authentication → EAB provisioning | §5.4, §8.2 | | **2** | RA requests credentials | OID4VP request from RA to EBW [MVP+] | §5.4 | -| **3** | User supplies EAA (POA) | EBW presents POA to RA | §5.4 | +| **3** | User supplies EAA (EBWOID) | EBW presents EBWOID to RA | §5.4 | | **4** | RA requests additional WRPRC attributes | Collected during EAB provisioning or as order metadata | §8.3 | | **5** | RA checks RP in authorized RP lists | `registrar-api-01` challenge validation | §6.4, §8.4 | | **6** | RA orders issuance of both certificates | ACME `finalize` (CSR submission) | §8.5 | @@ -656,7 +656,7 @@ Profiles for specific WE BUILD credential types MUST NOT relax these requirement **Key design decisions:** -* **Steps 1-3** (EBW auth + POA) are a **pre-ACME phase** producing EAB credentials. This cleanly separates human authentication from the machine protocol. Full OID4VP-based EBW authentication is required for MVP+ conformance. +* **Steps 1-3** (EBW auth + EBWOID) are a **pre-ACME phase** producing EAB credentials. This cleanly separates organisational authentication from the machine protocol. Authorisation derives from control of the organisation's EBW and the EBWOID it presents; the EBWOID's `id`/`name` are matched against the order's `wrp-id` (§7.2 item 9). Full OID4VP-based EBW authentication is required for MVP+ conformance. * **Step 4** (WRPRC attributes) is collected either during EAB provisioning or as ACME order metadata. * **Step 5** (RP list check) maps directly to the `registrar-api-01` challenge. * **Steps 6-8** (order, issue, transmit) map to ACME `finalize` → `processing` → `valid`. @@ -703,4 +703,6 @@ Profiles for specific WE BUILD credential types MUST NOT relax these requirement [18] ETSI EN 319 412-1 — Electronic Signatures and Trust Infrastructures (ESI); Certificate Profiles; Part 1: Overview and common data structures. -[19] IETF (2019) RFC 8615 — Well-Known Uniform Resource Identifiers (URIs). Standards Track, May 2019. https://www.rfc-editor.org/rfc/rfc8615 \ No newline at end of file +[19] IETF (2019) RFC 8615 — Well-Known Uniform Resource Identifiers (URIs). Standards Track, May 2019. https://www.rfc-editor.org/rfc/rfc8615 + +[20] WE BUILD Attestation Rulebooks Catalog — EBWOID Attestation Rulebook (rb-ebwoid). https://github.com/webuild-consortium/webuild-attestation-rulebooks-catalog/tree/main/rulebooks/rb-ebwoid \ No newline at end of file From f5ec7bec2772b57d935e83a41a7c7d59b8557e56 Mon Sep 17 00:00:00 2001 From: Jilles van Oossanen Date: Mon, 15 Jun 2026 15:45:23 +0200 Subject: [PATCH 07/14] deployment: enforce EBWOID-to-wrp-id match in CS-06 MVP facade Implements the new normative check (CS-06 7.2 item 9): the EBWOID bound to the ACME account must correspond to the WRP being ordered. - client: EAB now carries a simulated EBWOID {id, name} claim in its protected header (the identity the RA would verify out-of-band). - facade: newAccount extracts and stores the EBWOID; registrar-api-01 challenge validation rejects (403) unless EBWOID.id == wrp-id and EBWOID.name == the RP List entry's legal name. - negative_tests: fresh_account carries a (default-matching) EBWOID and a new t_challenge_ebwoid_mismatch asserts a 403 when EBWOID.id differs from the ordered wrp-id. - README: documents the EBWOID claim and the 7.2 item 9 enforcement; notes the EBWOID is trusted as-presented (HMAC still not verified) in this MVP. Not run end-to-end here (requires the live Dogtag CA + FastAPI services); py_compile passes and the seeded test identity matches the new check. Co-Authored-By: Claude Opus 4.8 (1M context) --- deployment/README.md | 16 ++--- .../files/wrpac-acme/wrpac_acme/client.py | 15 +++-- .../files/wrpac-acme/wrpac_acme/facade.py | 61 ++++++++++++++++++- .../wrpac-acme/wrpac_acme/negative_tests.py | 37 ++++++++++- 4 files changed, 112 insertions(+), 17 deletions(-) diff --git a/deployment/README.md b/deployment/README.md index b69d0eb8..30dfb472 100644 --- a/deployment/README.md +++ b/deployment/README.md @@ -21,11 +21,12 @@ of a CSR with a real WRPAC Subject DN (`O=` legal name + `organizationIdentifier **Phase 3** — minimal CS-06 ACME façade (Python/FastAPI) + mock Registrar (RP List service) sitting in front of the Dogtag CA. Implements `wrp-id` identifier -(CS-06 §5.2), `registrar-api-01` challenge (§5.3), EBW-EAB **stub** (§5.4 MVP -path — accepts any EAB blob without HMAC verification), and synchronous -finalize → CSR-Subject vs RP-List cross-check → `pki ca-cert-request-submit ---profile wrpacCert`. Verified end-to-end by a Python ACME client (`acme.sh` -can't speak `wrp-id`). +(CS-06 §5.2), `registrar-api-01` challenge (§5.3) with the EBWOID↔wrp-id match +(§7.2 #9: `EBWOID.id == wrp-id`, `EBWOID.name == RP List legal name`), EBW-EAB +**stub** (§5.4 MVP path — EAB carries a simulated EBWOID `{id, name}` claim but +the HMAC is not verified), and synchronous finalize → CSR-Subject vs RP-List +cross-check → `pki ca-cert-request-submit --profile wrpacCert`. Verified +end-to-end by a Python ACME client (`acme.sh` can't speak `wrp-id`). ## Layout @@ -122,14 +123,15 @@ Before any non-throwaway use, replace them with an `ansible-vault` encrypted fil - `registrar-api-01` challenge (§5.3) — façade fetches RP List, verifies `acme-challenge` against expected key-authorization - WRPAC certificate profile (§7.4) — Dogtag `wrpacCert`, 1-year, RSA-3072+/ECDSA P-256/P-384, policy OID, server-built Subject DN - Mock Registrar / RP List (§7.8) — single-instance shorthand form -- EAB **structure** at newAccount (§5.4) — blob accepted, HMAC not verified +- EAB **structure** at newAccount (§5.4) — blob accepted, carries a simulated EBWOID `{id, name}`; HMAC not verified +- EBWOID↔wrp-id match (§7.2 #9) — challenge validation enforces `EBWOID.id == wrp-id` and `EBWOID.name == RP List legal name` ### Still missing | Gap | Spec ref | Notes | |---|---|---| | HTTPS on the façade | §7.1 #2 | Façade currently binds plain HTTP on :9080. Put nginx in front, or have uvicorn terminate TLS with a Dogtag-issued cert. | -| Real EAB HMAC verification | §5.4 | Currently a stub. Needs an EAB-credentials store (Key ID → HMAC key) populated by the RA, and JWS HMAC verify on newAccount. | +| Real EAB HMAC verification | §5.4 | Currently a stub. Needs an EAB-credentials store (Key ID → HMAC key) populated by the RA, and JWS HMAC verify on newAccount. The EBWOID `{id, name}` the EAB carries is likewise trusted as-presented, not cryptographically bound. | | Nonce replay protection | §7.1 #4 | Façade issues fresh nonces but does not verify incoming ones. | | `keyChange`, real `revokeCert` | §5.1, §7.6 | Stubs / unimplemented. | | Multi-instance `instanceId` | §5.2, §8.3 | Single-instance only today. | diff --git a/deployment/files/wrpac-acme/wrpac_acme/client.py b/deployment/files/wrpac-acme/wrpac_acme/client.py index a4c48255..9ece6746 100644 --- a/deployment/files/wrpac-acme/wrpac_acme/client.py +++ b/deployment/files/wrpac-acme/wrpac_acme/client.py @@ -90,10 +90,16 @@ def _post(self, url: str, *, payload: Optional[Dict[str, Any]] = None, # ----- ACME flow ----- - def new_account_with_eab(self, contact: list[str], wrp_id: str) -> None: - # Stub EAB: a non-empty JWS-shaped blob the façade will accept. + def new_account_with_eab(self, contact: list[str], wrp_id: str, + legal_name: str) -> None: + # Stub EAB carrying the (simulated) EBWOID identity the RA "verified". + # In production the RA issues EAB credentials bound to the EBWOID; here + # the EBWOID {id, name} travels in the EAB protected header so the + # façade can enforce the EBWOID↔wrp-id match (CS-06 §5.4, §7.2 #9). eab_protected = b64u(json.dumps({"alg": "HS256", "kid": f"eab-{wrp_id}", - "url": self.directory["newAccount"]}).encode()) + "url": self.directory["newAccount"], + "ebwoid": {"id": wrp_id, + "name": legal_name}}).encode()) eab_payload = b64u(self.account_key.export_public().encode()) eab_signature = b64u(b"STUB-NOT-AN-HMAC") # CS-06 §5.4 MVP path payload = { @@ -192,7 +198,8 @@ def main() -> int: c = ACMEClient(args.directory, args.rp_list) c.fetch_directory() c.fetch_nonce() - c.new_account_with_eab([f"mailto:smoke@{args.wrp_id}"], args.wrp_id) + c.new_account_with_eab([f"mailto:smoke@{args.wrp_id}"], args.wrp_id, + args.legal_name) order = c.new_order(args.wrp_id) # Walk authorizations diff --git a/deployment/files/wrpac-acme/wrpac_acme/facade.py b/deployment/files/wrpac-acme/wrpac_acme/facade.py index cce71189..4c3f7022 100644 --- a/deployment/files/wrpac-acme/wrpac_acme/facade.py +++ b/deployment/files/wrpac-acme/wrpac_acme/facade.py @@ -4,9 +4,12 @@ ----------- * /acme-eudi-wrpac/directory with `wrp-id` and `registrar-api-01` in meta * JWS-signed requests per RFC 8555 §6 -* newAccount with EAB stub (CS-06 §5.4 MVP path — EAB blob required but not HMAC-verified) +* newAccount with EAB carrying a (simulated) EBWOID {id, name} claim + (CS-06 §5.4 MVP path — EAB blob required, HMAC not verified) * newOrder accepting `{type:"wrp-id", value: }` -* registrar-api-01 challenge: fetches RP List, compares key-authorization +* registrar-api-01 challenge: fetches RP List, compares key-authorization, + and enforces the EBWOID↔wrp-id match (CS-06 §7.2 #9: EBWOID.id == wrp-id, + EBWOID.name == RP List legal name) * finalize: verifies CSR Subject DN matches the RP List entry, forwards CSR to Dogtag (`pki ca-cert-request-submit --profile wrpacCert`), returns issued PEM @@ -52,6 +55,10 @@ class Account: jwk: jwk.JWK contact: List[str] = field(default_factory=list) status: str = "valid" + # Organisational identity from the EBWOID the RA verified (CS-06 §5.4). + # Matched against the order's wrp-id at challenge time (§7.2 #9). + ebwoid_id: Optional[str] = None + ebwoid_name: Optional[str] = None @dataclass @@ -91,6 +98,8 @@ def _account_to_dict(a: Account) -> dict: "jwk": json.loads(a.jwk.export(private_key=False)), "contact": list(a.contact), "status": a.status, + "ebwoid_id": a.ebwoid_id, + "ebwoid_name": a.ebwoid_name, } @@ -100,6 +109,8 @@ def _account_from_dict(d: dict) -> Account: jwk=jwk.JWK(**d["jwk"]), contact=list(d.get("contact", [])), status=d.get("status", "valid"), + ebwoid_id=d.get("ebwoid_id"), + ebwoid_name=d.get("ebwoid_name"), ) @@ -238,22 +249,50 @@ def _verify_with_account(body: dict) -> tuple[Account, dict, bytes]: # ---------- newAccount ---------- +def _eab_ebwoid(eab: dict) -> dict: + """Extract the (simulated) EBWOID {id, name} claim from the EAB protected + header. In production the RA resolves this from the EAB Key ID after + verifying the EBWOID via OID4VP; in this MVP the claim travels in the EAB + so the façade can enforce the EBWOID↔wrp-id match (CS-06 §5.4, §7.2 #9).""" + import base64 + import json as _json + try: + prot = eab["protected"] + pad = "=" * (-len(prot) % 4) + header = _json.loads(base64.urlsafe_b64decode(prot + pad)) + except Exception: + return {} + ebwoid = header.get("ebwoid") + return ebwoid if isinstance(ebwoid, dict) else {} + + @app.post(ROOT + "/new-account") async def new_account(request: Request) -> JSONResponse: body = await _read_jws(request) parsed = parse_and_verify(body, account_jwk=None) payload = parsed.payload_json or {} - # CS-06 §5.4: EAB required. We accept any well-formed EAB blob (stub). + # CS-06 §5.4: EAB required. In production the RA issues these credentials + # bound to the organisation's verified EBWOID; here the EAB carries the + # (simulated) EBWOID identity {id, name} in its protected header so the + # façade can enforce the EBWOID↔wrp-id match at challenge time (§7.2 #9). eab = payload.get("externalAccountBinding") if not eab or not all(k in eab for k in ("protected", "payload", "signature")): raise HTTPException(400, "externalAccountBinding required (CS-06 §5.4)") + ebwoid = _eab_ebwoid(eab) + if not ebwoid.get("id") or not ebwoid.get("name"): + raise HTTPException( + 400, + "externalAccountBinding must carry an EBWOID {id, name} claim (CS-06 §5.4)", + ) account_id = uuid.uuid4().hex[:16] _accounts[account_id] = Account( id=account_id, jwk=parsed.jwk_obj, contact=payload.get("contact", []), + ebwoid_id=ebwoid["id"], + ebwoid_name=ebwoid["name"], ) resp = JSONResponse( { @@ -393,6 +432,22 @@ async def challenge(chall_id: str, request: Request) -> JSONResponse: _challenges.save(c.id); _authzs.save(a.id) raise HTTPException(403, "WRP not active") + # CS-06 §7.2 #9: the EBWOID bound to the account MUST correspond to the WRP + # being ordered — EBWOID.id == wrp-id and EBWOID.name == the RP List entry's + # WRP legal name. This is the direct, testable identity binding that replaces + # the looser POA-based check. + if (account.ebwoid_id != a.identifier_value + or account.ebwoid_name != entry.get("legal_name")): + c.status = "invalid" + a.status = "invalid" + _challenges.save(c.id); _authzs.save(a.id) + raise HTTPException( + 403, + "EBWOID does not correspond to the WRP: " + f"ebwoid.id={account.ebwoid_id!r} wrp-id={a.identifier_value!r}, " + f"ebwoid.name={account.ebwoid_name!r} legal_name={entry.get('legal_name')!r}", + ) + # acme-challenge may be a bare string (single-instance shorthand) or a # structured object per CS-06 §7.8. We only support single-instance. chal = entry.get("acme_challenge") diff --git a/deployment/files/wrpac-acme/wrpac_acme/negative_tests.py b/deployment/files/wrpac-acme/wrpac_acme/negative_tests.py index c2f69660..2a6efef5 100644 --- a/deployment/files/wrpac-acme/wrpac_acme/negative_tests.py +++ b/deployment/files/wrpac-acme/wrpac_acme/negative_tests.py @@ -65,12 +65,19 @@ def setup(self) -> None: def fresh_nonce(self) -> str: return self.http.head(self.dir["newNonce"]).headers["Replay-Nonce"] - def fresh_account(self) -> Tuple[jwk.JWK, str]: - """Successfully create a new account and return (key, account_url).""" + def fresh_account(self, ebwoid_id: str = "NLKVK.12345678", + ebwoid_name: str = "ACME Test Organisation B.V.", + ) -> Tuple[jwk.JWK, str]: + """Successfully create a new account and return (key, account_url). + + The EAB carries a (simulated) EBWOID {id, name}; the defaults match the + seeded WRP so the account passes the §7.2 #9 EBWOID↔wrp-id check.""" key = jwk.JWK.generate(kty="RSA", size=2048) nonce = self.fresh_nonce() eab_protected = b64u(json.dumps({"alg": "HS256", "kid": "eab-test", - "url": self.dir["newAccount"]}).encode()) + "url": self.dir["newAccount"], + "ebwoid": {"id": ebwoid_id, + "name": ebwoid_name}}).encode()) eab_payload = b64u(key.export_public().encode()) eab_sig = b64u(b"STUB") payload = { @@ -159,6 +166,29 @@ def t_challenge_wrong_keyauth(self) -> None: self.record(r.status_code == 403, name, f"expected 403, got {r.status_code}: {r.text[:200]}") + def t_challenge_ebwoid_mismatch(self) -> None: + name = "registrar-api-01 fails when EBWOID.id != ordered wrp-id (§7.2 #9)" + # Account's EBWOID is for a DIFFERENT organisation than the WRP ordered. + key, acct = self.fresh_account(ebwoid_id="NLKVK.99999999", + ebwoid_name="Someone Else B.V.") + r = self.post(self.dir["newOrder"], key, acct, + {"identifiers": [{"type": "wrp-id", + "value": "NLKVK.12345678"}]}) + if r.status_code not in (200, 201): + self.record(False, name, f"newOrder failed unexpectedly: {r.status_code}") + return + order = r.json() + authz = self.post(order["authorizations"][0], key, acct, None).json() + chall = next(c for c in authz["challenges"] + if c["type"] == "registrar-api-01") + # Place the CORRECT key-auth, so the only failing check is the EBWOID match. + key_auth = f"{chall['token']}.{key.thumbprint()}" + self.http.put(f"{self.rp_list_url}/rp-list/NLKVK.12345678/acme-challenge", + json={"key_authorization": key_auth}) + r = self.post(chall["url"], key, acct, {}) + self.record(r.status_code == 403, name, + f"expected 403, got {r.status_code}: {r.text[:200]}") + def t_finalize_subject_mismatch(self) -> None: name = "finalize rejects CSR whose Subject doesn't match RP List entry" key, acct = self.fresh_account() @@ -215,6 +245,7 @@ def main() -> int: r.t_neworder_bad_identifier_type, r.t_challenge_unknown_wrp, r.t_challenge_wrong_keyauth, + r.t_challenge_ebwoid_mismatch, r.t_finalize_subject_mismatch, r.t_revoke_unimplemented, ): From 56c535244eff8e8edbb55b35a310204fa9134ee4 Mon Sep 17 00:00:00 2001 From: Sander Dijkhuis Date: Thu, 6 Aug 2026 22:13:42 +0200 Subject: [PATCH 08/14] chore: omit CA prototype from WBCS proposal Better move to the wp4-qtsp-group repository. --- .gitignore | 3 +- deployment/.gitignore | 5 - deployment/README.md | 152 ----- deployment/ansible.cfg | 11 - deployment/files/wrpac-acme/pyproject.toml | 21 - .../wrpac-acme/wrpac_acme.egg-info/PKG-INFO | 11 - .../wrpac_acme.egg-info/SOURCES.txt | 13 - .../wrpac_acme.egg-info/dependency_links.txt | 1 - .../wrpac_acme.egg-info/requires.txt | 6 - .../wrpac_acme.egg-info/top_level.txt | 1 - .../files/wrpac-acme/wrpac_acme/__init__.py | 1 - .../files/wrpac-acme/wrpac_acme/client.py | 250 -------- .../files/wrpac-acme/wrpac_acme/crypto.py | 92 --- .../files/wrpac-acme/wrpac_acme/dogtag.py | 91 --- .../files/wrpac-acme/wrpac_acme/facade.py | 574 ------------------ .../wrpac-acme/wrpac_acme/negative_tests.py | 263 -------- .../files/wrpac-acme/wrpac_acme/rp_list.py | 129 ---- .../files/wrpac-acme/wrpac_acme/storage.py | 79 --- deployment/files/wrpacCert.cfg.j2 | 141 ----- deployment/group_vars/all.yml | 53 -- deployment/inventory.yml | 6 - deployment/nginx/ca-public.conf | 81 --- deployment/playbooks/00-prereqs.yml | 74 --- deployment/playbooks/10-dogtag-ca.yml | 108 ---- deployment/playbooks/20-dogtag-acme.yml | 125 ---- deployment/playbooks/21-acme-persistence.yml | 177 ------ deployment/playbooks/30-wrpac-profile.yml | 75 --- .../playbooks/31-wrpac-smoke-direct.yml | 194 ------ deployment/playbooks/40-wrpac-facade.yml | 184 ------ deployment/playbooks/99-smoke-test.yml | 106 ---- deployment/requirements.txt | 1 - deployment/requirements.yml | 6 - deployment/site.yml | 10 - deployment/test-out/dogtag-ca-root.pem | 27 - deployment/test-out/phase3-1779199136.pem | 26 - deployment/test-out/phase3-1779199294.pem | 26 - deployment/test-out/phase3-1779201344.pem | 26 - deployment/test-out/phase3-1779202392.pem | 26 - deployment/test-out/profile-check.pem | 26 - deployment/tests/README.md | 45 -- deployment/tests/test_01_directory.yml | 82 --- deployment/tests/test_02_phase1_acme.yml | 61 -- deployment/tests/test_03_phase2_direct.yml | 67 -- deployment/tests/test_04_phase3_happy.yml | 43 -- deployment/tests/test_05_phase3_negative.yml | 31 - deployment/tests/test_06_cert_profile.yml | 120 ---- deployment/tests/test_07_persistence.yml | 86 --- .../tests/test_08_facade_persistence.yml | 111 ---- deployment/tests/test_all.yml | 12 - 49 files changed, 1 insertion(+), 3858 deletions(-) delete mode 100644 deployment/.gitignore delete mode 100644 deployment/README.md delete mode 100644 deployment/ansible.cfg delete mode 100644 deployment/files/wrpac-acme/pyproject.toml delete mode 100644 deployment/files/wrpac-acme/wrpac_acme.egg-info/PKG-INFO delete mode 100644 deployment/files/wrpac-acme/wrpac_acme.egg-info/SOURCES.txt delete mode 100644 deployment/files/wrpac-acme/wrpac_acme.egg-info/dependency_links.txt delete mode 100644 deployment/files/wrpac-acme/wrpac_acme.egg-info/requires.txt delete mode 100644 deployment/files/wrpac-acme/wrpac_acme.egg-info/top_level.txt delete mode 100644 deployment/files/wrpac-acme/wrpac_acme/__init__.py delete mode 100644 deployment/files/wrpac-acme/wrpac_acme/client.py delete mode 100644 deployment/files/wrpac-acme/wrpac_acme/crypto.py delete mode 100644 deployment/files/wrpac-acme/wrpac_acme/dogtag.py delete mode 100644 deployment/files/wrpac-acme/wrpac_acme/facade.py delete mode 100644 deployment/files/wrpac-acme/wrpac_acme/negative_tests.py delete mode 100644 deployment/files/wrpac-acme/wrpac_acme/rp_list.py delete mode 100644 deployment/files/wrpac-acme/wrpac_acme/storage.py delete mode 100644 deployment/files/wrpacCert.cfg.j2 delete mode 100644 deployment/group_vars/all.yml delete mode 100644 deployment/inventory.yml delete mode 100644 deployment/nginx/ca-public.conf delete mode 100644 deployment/playbooks/00-prereqs.yml delete mode 100644 deployment/playbooks/10-dogtag-ca.yml delete mode 100644 deployment/playbooks/20-dogtag-acme.yml delete mode 100644 deployment/playbooks/21-acme-persistence.yml delete mode 100644 deployment/playbooks/30-wrpac-profile.yml delete mode 100644 deployment/playbooks/31-wrpac-smoke-direct.yml delete mode 100644 deployment/playbooks/40-wrpac-facade.yml delete mode 100644 deployment/playbooks/99-smoke-test.yml delete mode 100644 deployment/requirements.txt delete mode 100644 deployment/requirements.yml delete mode 100644 deployment/site.yml delete mode 100644 deployment/test-out/dogtag-ca-root.pem delete mode 100644 deployment/test-out/phase3-1779199136.pem delete mode 100644 deployment/test-out/phase3-1779199294.pem delete mode 100644 deployment/test-out/phase3-1779201344.pem delete mode 100644 deployment/test-out/phase3-1779202392.pem delete mode 100644 deployment/test-out/profile-check.pem delete mode 100644 deployment/tests/README.md delete mode 100644 deployment/tests/test_01_directory.yml delete mode 100644 deployment/tests/test_02_phase1_acme.yml delete mode 100644 deployment/tests/test_03_phase2_direct.yml delete mode 100644 deployment/tests/test_04_phase3_happy.yml delete mode 100644 deployment/tests/test_05_phase3_negative.yml delete mode 100644 deployment/tests/test_06_cert_profile.yml delete mode 100644 deployment/tests/test_07_persistence.yml delete mode 100644 deployment/tests/test_08_facade_persistence.yml delete mode 100644 deployment/tests/test_all.yml diff --git a/.gitignore b/.gitignore index ecd7542c..293368da 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ .DS_Store -*.bak -CLAUDE.md +*.bak \ No newline at end of file diff --git a/deployment/.gitignore b/deployment/.gitignore deleted file mode 100644 index 9018780f..00000000 --- a/deployment/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -.venv/ -*.retry -collections/ -roles/galaxy/ -__pycache__/ diff --git a/deployment/README.md b/deployment/README.md deleted file mode 100644 index 30dfb472..00000000 --- a/deployment/README.md +++ /dev/null @@ -1,152 +0,0 @@ -# WE BUILD RPAC CA — Ansible deployment - -> **Scope.** This directory contains the **deployment artefacts** for a Dogtag PKI CA -> targeted at `mw-vm-ca01.lab` for WE BUILD interoperability testing of -> [CS-06](../conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md). -> -> It is **not** governance documentation. It is also **not yet** a CS-06-conformant -> deployment — see "Remaining gaps" below. - -## Phases delivered - -**Phase 1** — stock RFC 8555 ACME endpoint backed by Dogtag PKI on `mw-vm-ca01.lab`, -verified end-to-end with `acme.sh` (http-01 challenge, issues from the stock -`acmeServerCert` profile: RSA-only, 90-day). - -**Phase 2** — custom `wrpacCert` profile that matches CS-06 §7.4 (1-year validity, -RSA-3072+/ECDSA P-256/P-384, SHA-256+, Subject DN constraint requiring -`organizationIdentifier=`, WRPAC policy OID, SAN). Verified by direct enrolment -of a CSR with a real WRPAC Subject DN (`O=` legal name + `organizationIdentifier=` -+ `C=` + `CN=` friendly name) using the `pki` CLI as a CA agent. - -**Phase 3** — minimal CS-06 ACME façade (Python/FastAPI) + mock Registrar (RP List -service) sitting in front of the Dogtag CA. Implements `wrp-id` identifier -(CS-06 §5.2), `registrar-api-01` challenge (§5.3) with the EBWOID↔wrp-id match -(§7.2 #9: `EBWOID.id == wrp-id`, `EBWOID.name == RP List legal name`), EBW-EAB -**stub** (§5.4 MVP path — EAB carries a simulated EBWOID `{id, name}` claim but -the HMAC is not verified), and synchronous finalize → CSR-Subject vs RP-List -cross-check → `pki ca-cert-request-submit --profile wrpacCert`. Verified -end-to-end by a Python ACME client (`acme.sh` can't speak `wrp-id`). - -## Layout - -``` -deployment/ -├── ansible.cfg base config (inventory, ssh) -├── inventory.yml single host: mw-vm-ca01.lab as cbadmin -├── group_vars/all.yml CA FQDN, passwords (test-only), DS suffix, -│ WRPAC test identity, placeholder policy OIDs -├── site.yml imports the playbooks in order -├── files/ -│ ├── wrpacCert.cfg.j2 Dogtag profile template (CS-06 §7.4) -│ └── wrpac-acme/ Phase 3 Python project (façade + RP List + client) -│ ├── pyproject.toml -│ └── wrpac_acme/ -│ ├── rp_list.py Mock Registrar / RP List service (port 9000) -│ ├── facade.py ACME façade (port 9080) -│ ├── crypto.py JWS / JWK / key-authorization helpers -│ ├── dogtag.py Submits CSRs to Dogtag via `pki` CLI -│ └── client.py End-to-end Python ACME client (CS-06 §6 flow) -└── playbooks/ - ├── 00-prereqs.yml hostname, EPEL, firewalld, time sync, repos - ├── 10-dogtag-ca.yml 389-DS instance + pkispawn -s CA - ├── 20-dogtag-acme.yml pki-acme package + acme-create/acme-deploy - ├── 21-acme-persistence.yml switch Dogtag ACME backend from in-memory to 389-DS - ├── 30-wrpac-profile.yml install + register wrpacCert profile - ├── 31-wrpac-smoke-direct.yml direct CSR enrolment with WRPAC Subject DN - ├── 40-wrpac-facade.yml sync façade + RP List, systemd units, e2e smoke - └── 99-smoke-test.yml GET /acme/directory + acme.sh test issuance - -tests/ -├── README.md how to run; var reference -├── test_all.yml master — runs all six in order -├── test_01_directory.yml directories + RP List sanity [URL-flexible] -├── test_02_phase1_acme.yml acme.sh stock ACME http-01 issuance [local only] -├── test_03_phase2_direct.yml pki CLI direct enrolment with wrpacCert [local only] -├── test_04_phase3_happy.yml Python ACME client CS-06 §6 happy path [URL-flexible] -├── test_05_phase3_negative.yml CS-06 §7.2 MUST negative-test battery [URL-flexible] -├── test_06_cert_profile.yml Issue a fresh WRPAC + assert every §7.4 attribute [URL-flexible] -├── test_07_persistence.yml Dogtag ACME state survives pki-tomcatd restart [local only] -└── test_08_facade_persistence.yml CS-06 façade + RP List survive service restart [local only] -``` - -## Running the test suite - -```sh -# Against the local deployment (default) -ansible-playbook tests/test_all.yml - -# Against the public deployment -ansible-playbook tests/test_all.yml \ - -e test_acme_directory=https://rpca.lab.cleverbase.io/acme-eudi-wrpac/directory \ - -e test_rp_list=https://rpca.lab.cleverbase.io \ - -e test_acme_stock_directory=https://rpca.lab.cleverbase.io/acme/directory -``` - -Tests 02 and 03 always run on the CA host (they need box-local state: acme.sh's -cache, the pki NSS DB). Tests 01, 04, 05, 06 run from the control machine, so -they can target either internal or public URLs. - -## Run - -From this directory: - -```sh -python3 -m venv .venv -. .venv/bin/activate -pip install -r requirements.txt -ansible-galaxy collection install -r requirements.yml - -# Connectivity check -ansible -i inventory.yml -m ping all - -# Full deploy -ansible-playbook site.yml - -# Or step-by-step -ansible-playbook playbooks/00-prereqs.yml -ansible-playbook playbooks/10-dogtag-ca.yml -ansible-playbook playbooks/20-dogtag-acme.yml -ansible-playbook playbooks/99-smoke-test.yml -``` - -## Passwords - -Test-only passwords live in [group_vars/all.yml](group_vars/all.yml) (`Secret.123`). -Before any non-throwaway use, replace them with an `ansible-vault` encrypted file. - -## Remaining gaps before this is CS-06 conformant - -### Implemented (Phase 1–3) - -- `wrp-id` identifier type (§5.2) — façade-level -- `registrar-api-01` challenge (§5.3) — façade fetches RP List, verifies `acme-challenge` against expected key-authorization -- WRPAC certificate profile (§7.4) — Dogtag `wrpacCert`, 1-year, RSA-3072+/ECDSA P-256/P-384, policy OID, server-built Subject DN -- Mock Registrar / RP List (§7.8) — single-instance shorthand form -- EAB **structure** at newAccount (§5.4) — blob accepted, carries a simulated EBWOID `{id, name}`; HMAC not verified -- EBWOID↔wrp-id match (§7.2 #9) — challenge validation enforces `EBWOID.id == wrp-id` and `EBWOID.name == RP List legal name` - -### Still missing - -| Gap | Spec ref | Notes | -|---|---|---| -| HTTPS on the façade | §7.1 #2 | Façade currently binds plain HTTP on :9080. Put nginx in front, or have uvicorn terminate TLS with a Dogtag-issued cert. | -| Real EAB HMAC verification | §5.4 | Currently a stub. Needs an EAB-credentials store (Key ID → HMAC key) populated by the RA, and JWS HMAC verify on newAccount. The EBWOID `{id, name}` the EAB carries is likewise trusted as-presented, not cryptographically bound. | -| Nonce replay protection | §7.1 #4 | Façade issues fresh nonces but does not verify incoming ones. | -| `keyChange`, real `revokeCert` | §5.1, §7.6 | Stubs / unimplemented. | -| Multi-instance `instanceId` | §5.2, §8.3 | Single-instance only today. | -| `registrationCertificate` URL co-issuance | §5.1, §8.5 | The order object never carries this URL. | -| CT logging + SCTs | §7.5 | Neither Dogtag nor the façade log to CT today. | -| ~~Persistent state (Phase 3 façade)~~ | — | ✓ Done. Phase 3 façade state (accounts/orders/authz/challenges/certs) + mock RP List persist in SQLite under `/var/lib/wrpac-acme/`. See [`wrpac_acme/storage.py`](files/wrpac-acme/wrpac_acme/storage.py). Dogtag's own ACME state persists in 389-DS — see [21-acme-persistence.yml](playbooks/21-acme-persistence.yml). | -| Verified WRPAC policy OIDs | §7.4 #2 | Values in `group_vars/all.yml` are **placeholders** in the ETSI test arc. Replace with `NCP-l-eudiwrp` / `NCP-n-eudiwrp` from ETSI TS 119 411-8 v1.1.1. | -| Full OID4VP-based EBW authentication (MVP+) | §5.4, §6.2 | The RA + EBW flow that provisions EAB credentials is out of scope. | - -`99-smoke-test.yml` (Phase 1 stock-ACME smoke test against Dogtag's built-in -responder) is still wired up alongside the Phase 3 façade. They live on -different ports (8443 vs 9080) and don't interfere. - -## Tear-down - -The CA can be removed with `pkidestroy -s CA -i pki-tomcat` and the 389-DS -instance with `dsctl pki remove --do-it`. A tear-down playbook is not provided — -re-image the VM if you want a clean slate. diff --git a/deployment/ansible.cfg b/deployment/ansible.cfg deleted file mode 100644 index b3b6baec..00000000 --- a/deployment/ansible.cfg +++ /dev/null @@ -1,11 +0,0 @@ -[defaults] -inventory = inventory.yml -host_key_checking = True -stdout_callback = default -result_format = yaml -forks = 5 -retry_files_enabled = False -interpreter_python = auto_silent - -[ssh_connection] -pipelining = True diff --git a/deployment/files/wrpac-acme/pyproject.toml b/deployment/files/wrpac-acme/pyproject.toml deleted file mode 100644 index b420650c..00000000 --- a/deployment/files/wrpac-acme/pyproject.toml +++ /dev/null @@ -1,21 +0,0 @@ -[project] -name = "wrpac-acme" -version = "0.1.0" -description = "WE BUILD CS-06 ACME façade + mock RP List service for Dogtag PKI" -requires-python = ">=3.9" -dependencies = [ - "fastapi>=0.110,<0.116", - "uvicorn[standard]>=0.27,<0.32", - "pydantic>=2.5,<3", - "cryptography>=42,<44", - "jwcrypto>=1.5,<2", - "httpx>=0.26,<0.28", -] - -[build-system] -requires = ["setuptools>=64", "wheel"] -build-backend = "setuptools.build_meta" - -[tool.setuptools.packages.find] -where = ["."] -include = ["wrpac_acme*"] diff --git a/deployment/files/wrpac-acme/wrpac_acme.egg-info/PKG-INFO b/deployment/files/wrpac-acme/wrpac_acme.egg-info/PKG-INFO deleted file mode 100644 index 93508426..00000000 --- a/deployment/files/wrpac-acme/wrpac_acme.egg-info/PKG-INFO +++ /dev/null @@ -1,11 +0,0 @@ -Metadata-Version: 2.4 -Name: wrpac-acme -Version: 0.1.0 -Summary: WE BUILD CS-06 ACME façade + mock RP List service for Dogtag PKI -Requires-Python: >=3.9 -Requires-Dist: fastapi<0.116,>=0.110 -Requires-Dist: uvicorn[standard]<0.32,>=0.27 -Requires-Dist: pydantic<3,>=2.5 -Requires-Dist: cryptography<44,>=42 -Requires-Dist: jwcrypto<2,>=1.5 -Requires-Dist: httpx<0.28,>=0.26 diff --git a/deployment/files/wrpac-acme/wrpac_acme.egg-info/SOURCES.txt b/deployment/files/wrpac-acme/wrpac_acme.egg-info/SOURCES.txt deleted file mode 100644 index 7f751655..00000000 --- a/deployment/files/wrpac-acme/wrpac_acme.egg-info/SOURCES.txt +++ /dev/null @@ -1,13 +0,0 @@ -pyproject.toml -wrpac_acme/__init__.py -wrpac_acme/client.py -wrpac_acme/crypto.py -wrpac_acme/dogtag.py -wrpac_acme/facade.py -wrpac_acme/negative_tests.py -wrpac_acme/rp_list.py -wrpac_acme.egg-info/PKG-INFO -wrpac_acme.egg-info/SOURCES.txt -wrpac_acme.egg-info/dependency_links.txt -wrpac_acme.egg-info/requires.txt -wrpac_acme.egg-info/top_level.txt \ No newline at end of file diff --git a/deployment/files/wrpac-acme/wrpac_acme.egg-info/dependency_links.txt b/deployment/files/wrpac-acme/wrpac_acme.egg-info/dependency_links.txt deleted file mode 100644 index 8b137891..00000000 --- a/deployment/files/wrpac-acme/wrpac_acme.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/deployment/files/wrpac-acme/wrpac_acme.egg-info/requires.txt b/deployment/files/wrpac-acme/wrpac_acme.egg-info/requires.txt deleted file mode 100644 index c09fde1b..00000000 --- a/deployment/files/wrpac-acme/wrpac_acme.egg-info/requires.txt +++ /dev/null @@ -1,6 +0,0 @@ -fastapi<0.116,>=0.110 -uvicorn[standard]<0.32,>=0.27 -pydantic<3,>=2.5 -cryptography<44,>=42 -jwcrypto<2,>=1.5 -httpx<0.28,>=0.26 diff --git a/deployment/files/wrpac-acme/wrpac_acme.egg-info/top_level.txt b/deployment/files/wrpac-acme/wrpac_acme.egg-info/top_level.txt deleted file mode 100644 index 70d92af6..00000000 --- a/deployment/files/wrpac-acme/wrpac_acme.egg-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ -wrpac_acme diff --git a/deployment/files/wrpac-acme/wrpac_acme/__init__.py b/deployment/files/wrpac-acme/wrpac_acme/__init__.py deleted file mode 100644 index 98489472..00000000 --- a/deployment/files/wrpac-acme/wrpac_acme/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""WE BUILD CS-06 ACME façade + mock RP List service.""" diff --git a/deployment/files/wrpac-acme/wrpac_acme/client.py b/deployment/files/wrpac-acme/wrpac_acme/client.py deleted file mode 100644 index 9ece6746..00000000 --- a/deployment/files/wrpac-acme/wrpac_acme/client.py +++ /dev/null @@ -1,250 +0,0 @@ -"""End-to-end Python ACME client speaking CS-06 (wrp-id + registrar-api-01). - -Simulates a WRP/EBW issuing itself a WRPAC. - -Usage: - python -m wrpac_acme.client \ - --directory http://mw-vm-ca01.lab:9080/acme-eudi-wrpac/directory \ - --rp-list http://mw-vm-ca01.lab:9000 \ - --wrp-id NLKVK.12345678 \ - --legal-name "ACME Test Organisation B.V." \ - --org-id VATNL-000000000B01 \ - --country NL \ - --friendly-name "ACME RP Smoke Test" -""" -from __future__ import annotations - -import argparse -import base64 -import json -import sys -import time -from typing import Any, Dict, Optional - -import httpx -from cryptography import x509 -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import rsa -from cryptography.x509.oid import NameOID -from jwcrypto import jwk, jws - - -def b64u(b: bytes) -> str: - return base64.urlsafe_b64encode(b).rstrip(b"=").decode("ascii") - - -def sign(key: jwk.JWK, protected: Dict[str, Any], payload: bytes) -> Dict[str, str]: - token = jws.JWS(payload) - token.add_signature(key, alg=protected.get("alg", "RS256"), - protected=json.dumps(protected)) - return json.loads(token.serialize()) - - -class ACMEClient: - def __init__(self, directory_url: str, rp_list_url: str) -> None: - self.directory_url = directory_url - self.rp_list_url = rp_list_url.rstrip("/") - self.http = httpx.Client(timeout=15.0, verify=False) - self.directory: Dict[str, Any] = {} - self.nonce: Optional[str] = None - self.account_key = jwk.JWK.generate(kty="RSA", size=3072) - self.account_url: Optional[str] = None - - # ----- low-level helpers ----- - - def fetch_directory(self) -> None: - r = self.http.get(self.directory_url) - r.raise_for_status() - self.directory = r.json() - print("[client] directory:", json.dumps(self.directory, indent=2)) - - def fetch_nonce(self) -> None: - r = self.http.head(self.directory["newNonce"]) - r.raise_for_status() - self.nonce = r.headers["Replay-Nonce"] - - def _absorb_nonce(self, r: httpx.Response) -> None: - n = r.headers.get("Replay-Nonce") - if n: - self.nonce = n - - def _post(self, url: str, *, payload: Optional[Dict[str, Any]] = None, - use_jwk: bool = False, extra_protected: Optional[Dict[str, Any]] = None) -> httpx.Response: - if self.nonce is None: - self.fetch_nonce() - protected: Dict[str, Any] = {"alg": "RS256", "nonce": self.nonce, "url": url} - if use_jwk: - protected["jwk"] = json.loads(self.account_key.export_public()) - else: - assert self.account_url is not None - protected["kid"] = self.account_url - if extra_protected: - protected.update(extra_protected) - - payload_bytes = b"" if payload is None else json.dumps(payload).encode("utf-8") - body = sign(self.account_key, protected, payload_bytes) - r = self.http.post(url, json=body, - headers={"Content-Type": "application/jose+json"}) - self._absorb_nonce(r) - return r - - # ----- ACME flow ----- - - def new_account_with_eab(self, contact: list[str], wrp_id: str, - legal_name: str) -> None: - # Stub EAB carrying the (simulated) EBWOID identity the RA "verified". - # In production the RA issues EAB credentials bound to the EBWOID; here - # the EBWOID {id, name} travels in the EAB protected header so the - # façade can enforce the EBWOID↔wrp-id match (CS-06 §5.4, §7.2 #9). - eab_protected = b64u(json.dumps({"alg": "HS256", "kid": f"eab-{wrp_id}", - "url": self.directory["newAccount"], - "ebwoid": {"id": wrp_id, - "name": legal_name}}).encode()) - eab_payload = b64u(self.account_key.export_public().encode()) - eab_signature = b64u(b"STUB-NOT-AN-HMAC") # CS-06 §5.4 MVP path - payload = { - "termsOfServiceAgreed": True, - "contact": contact, - "externalAccountBinding": { - "protected": eab_protected, - "payload": eab_payload, - "signature": eab_signature, - }, - } - r = self._post(self.directory["newAccount"], payload=payload, use_jwk=True) - if r.status_code not in (200, 201): - raise RuntimeError(f"newAccount failed: {r.status_code} {r.text}") - self.account_url = r.headers["Location"] - print("[client] account:", self.account_url) - - def new_order(self, wrp_id: str) -> Dict[str, Any]: - r = self._post(self.directory["newOrder"], - payload={"identifiers": [{"type": "wrp-id", "value": wrp_id}]}) - if r.status_code not in (200, 201): - raise RuntimeError(f"newOrder failed: {r.status_code} {r.text}") - order = r.json() - order["_url"] = r.headers["Location"] - print("[client] order:", json.dumps(order, indent=2)) - return order - - def get_authz(self, authz_url: str) -> Dict[str, Any]: - r = self._post(authz_url, payload=None) # POST-as-GET - r.raise_for_status() - return r.json() - - def post_challenge(self, chall_url: str) -> Dict[str, Any]: - r = self._post(chall_url, payload={}) - if r.status_code not in (200, 202): - raise RuntimeError(f"challenge POST failed: {r.status_code} {r.text}") - return r.json() - - def finalize(self, finalize_url: str, csr_der: bytes) -> Dict[str, Any]: - r = self._post(finalize_url, payload={"csr": b64u(csr_der)}) - if r.status_code not in (200, 201): - raise RuntimeError(f"finalize failed: {r.status_code} {r.text}") - return r.json() - - def get_order(self, order_url: str) -> Dict[str, Any]: - r = self._post(order_url, payload=None) - r.raise_for_status() - return r.json() - - def download_cert(self, cert_url: str) -> str: - r = self._post(cert_url, payload=None) - r.raise_for_status() - return r.text - - # ----- RP List helpers (out-of-band, not part of ACME) ----- - - def put_acme_challenge(self, wrp_id: str, key_auth: str) -> None: - r = self.http.put(f"{self.rp_list_url}/rp-list/{wrp_id}/acme-challenge", - json={"key_authorization": key_auth}) - r.raise_for_status() - - -def build_csr(legal_name: str, org_id: str, country: str, - friendly_name: str) -> tuple[bytes, rsa.RSAPrivateKey]: - key = rsa.generate_private_key(public_exponent=65537, key_size=3072) - subject = x509.Name([ - x509.NameAttribute(NameOID.COUNTRY_NAME, country), - x509.NameAttribute(x509.ObjectIdentifier("2.5.4.97"), org_id), - x509.NameAttribute(NameOID.ORGANIZATION_NAME, legal_name), - x509.NameAttribute(NameOID.COMMON_NAME, friendly_name), - ]) - csr = x509.CertificateSigningRequestBuilder() \ - .subject_name(subject) \ - .add_extension(x509.SubjectAlternativeName([x509.DNSName("rp.test.lab")]), - critical=False) \ - .sign(key, hashes.SHA256()) - return csr.public_bytes(serialization.Encoding.DER), key - - -def jwk_thumbprint(key: jwk.JWK) -> str: - return key.thumbprint() - - -def main() -> int: - ap = argparse.ArgumentParser() - ap.add_argument("--directory", required=True) - ap.add_argument("--rp-list", required=True) - ap.add_argument("--wrp-id", required=True) - ap.add_argument("--legal-name", required=True) - ap.add_argument("--org-id", required=True) - ap.add_argument("--country", default="NL") - ap.add_argument("--friendly-name", required=True) - ap.add_argument("--out-cert", default="/tmp/wrpac-issued.pem") - args = ap.parse_args() - - c = ACMEClient(args.directory, args.rp_list) - c.fetch_directory() - c.fetch_nonce() - c.new_account_with_eab([f"mailto:smoke@{args.wrp_id}"], args.wrp_id, - args.legal_name) - order = c.new_order(args.wrp_id) - - # Walk authorizations - for authz_url in order["authorizations"]: - authz = c.get_authz(authz_url) - print("[client] authz:", json.dumps(authz, indent=2)) - chall = next(ch for ch in authz["challenges"] if ch["type"] == "registrar-api-01") - key_auth = f"{chall['token']}.{jwk_thumbprint(c.account_key)}" - print(f"[client] placing key-auth in RP List for {args.wrp_id}") - c.put_acme_challenge(args.wrp_id, key_auth) - print("[client] signalling readiness on challenge URL") - c.post_challenge(chall["url"]) - # Poll authz until valid (façade transitions synchronously, so one poll suffices) - for _ in range(10): - authz = c.get_authz(authz_url) - if authz["status"] == "valid": - break - if authz["status"] == "invalid": - print("[client] authz invalid:", authz, file=sys.stderr) - return 1 - time.sleep(1) - - csr_der, _key = build_csr(args.legal_name, args.org_id, args.country, args.friendly_name) - order2 = c.finalize(order["finalize"], csr_der) - print("[client] order after finalize:", json.dumps(order2, indent=2)) - - if "certificate" not in order2: - # Poll until certificate URL appears - for _ in range(10): - order2 = c.get_order(order["_url"]) - if order2.get("certificate"): - break - time.sleep(1) - else: - print("[client] no certificate URL appeared", file=sys.stderr) - return 1 - - pem = c.download_cert(order2["certificate"]) - with open(args.out_cert, "w") as f: - f.write(pem) - print(f"[client] issued WRPAC written to {args.out_cert}") - print("---- PEM ----") - print(pem) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/deployment/files/wrpac-acme/wrpac_acme/crypto.py b/deployment/files/wrpac-acme/wrpac_acme/crypto.py deleted file mode 100644 index 90f4e9cd..00000000 --- a/deployment/files/wrpac-acme/wrpac_acme/crypto.py +++ /dev/null @@ -1,92 +0,0 @@ -"""JWS / JWK helpers used by the ACME façade. - -Just enough crypto to: - - parse and verify a flattened JWS request body (RFC 8555 §6.2) - - compute the JWK thumbprint per RFC 7638 - - derive the key-authorization per RFC 8555 §8.1 - -For Phase 3 simplicity we DO NOT verify nonces here — the façade just issues -fresh ones and never checks. Replay protection is out of scope for the -interop demo. -""" -from __future__ import annotations - -import base64 -import json -from dataclasses import dataclass -from typing import Optional - -from jwcrypto import jwk, jws - - -def b64u(data: bytes) -> str: - return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") - - -def b64u_decode(data: str) -> bytes: - padding = "=" * (-len(data) % 4) - return base64.urlsafe_b64decode(data + padding) - - -@dataclass -class ParsedJWS: - protected: dict - payload: bytes - signature_valid: bool - jwk_obj: jwk.JWK # The verifying key - payload_json: Optional[dict] = None - - -def parse_and_verify( - body: dict, - *, - account_jwk: Optional[jwk.JWK] = None, -) -> ParsedJWS: - """Parse a flattened JWS request body and verify the signature. - - If `account_jwk` is given, that's the key the JWS must verify against - (post-newAccount requests use `kid`). Otherwise the JWS protected - header MUST carry an embedded `jwk` (newAccount, revokeCert by cert key). - """ - if not all(k in body for k in ("protected", "payload", "signature")): - raise ValueError("not a flattened JWS") - - protected_raw = b64u_decode(body["protected"]) - protected = json.loads(protected_raw) - - if account_jwk is None: - if "jwk" not in protected: - raise ValueError("no jwk in protected header and no account key supplied") - verify_key = jwk.JWK(**protected["jwk"]) - else: - verify_key = account_jwk - - token = jws.JWS() - token.deserialize(json.dumps(body)) - token.verify(verify_key) - - payload = token.payload # bytes - payload_json: Optional[dict] = None - if payload: - try: - payload_json = json.loads(payload) - except json.JSONDecodeError: - payload_json = None - - return ParsedJWS( - protected=protected, - payload=payload, - signature_valid=True, - jwk_obj=verify_key, - payload_json=payload_json, - ) - - -def jwk_thumbprint(key: jwk.JWK) -> str: - """RFC 7638 JWK thumbprint, returned as base64url-no-pad.""" - return key.thumbprint() - - -def key_authorization(token: str, key: jwk.JWK) -> str: - """RFC 8555 §8.1: token || '.' || base64url(Thumbprint(accountKey)).""" - return f"{token}.{jwk_thumbprint(key)}" diff --git a/deployment/files/wrpac-acme/wrpac_acme/dogtag.py b/deployment/files/wrpac-acme/wrpac_acme/dogtag.py deleted file mode 100644 index 1d310af0..00000000 --- a/deployment/files/wrpac-acme/wrpac_acme/dogtag.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Thin wrapper around the `pki` CLI to submit a CSR to Dogtag. - -The façade reuses the NSS DB set up by Phase 2's direct-enrolment smoke test -(/root/wrpac-smoke/nssdb) which already contains the caadmin client cert and -the trusted CA signing cert. -""" -from __future__ import annotations - -import asyncio -import os -import re -import subprocess -import tempfile -from dataclasses import dataclass - - -@dataclass -class DogtagConfig: - nss_dir: str = os.getenv("WRPAC_NSS_DIR", "/root/wrpac-smoke/nssdb") - nss_password: str = os.getenv("WRPAC_NSS_PASSWORD", "Secret.123") - admin_nickname: str = os.getenv("WRPAC_ADMIN_NICKNAME", "PKI Administrator for lab") - ca_url: str = os.getenv("WRPAC_CA_URL", "https://mw-vm-ca01.lab:8443") - profile_id: str = os.getenv("WRPAC_PROFILE_ID", "wrpacCert") - - -_CFG = DogtagConfig() - - -def _pki_base() -> list[str]: - return [ - "pki", - "-d", _CFG.nss_dir, - "-c", _CFG.nss_password, - "-n", _CFG.admin_nickname, - "-U", _CFG.ca_url, - ] - - -_CERT_ID_RE = re.compile(r"Certificate ID:\s*(\S+)") - - -def _sync_submit_csr(csr_pem: bytes) -> str: - """Submit a PEM-encoded CSR via the pki CLI and return the issued cert (PEM).""" - with tempfile.TemporaryDirectory() as tmp: - csr_path = os.path.join(tmp, "req.csr") - cert_path = os.path.join(tmp, "cert.pem") - with open(csr_path, "wb") as f: - f.write(csr_pem) - - submit = subprocess.run( - _pki_base() + [ - "ca-cert-request-submit", - "--profile", _CFG.profile_id, - "--csr-file", csr_path, - ], - capture_output=True, - text=True, - ) - if submit.returncode != 0: - raise RuntimeError( - f"pki ca-cert-request-submit failed: rc={submit.returncode} " - f"stderr={submit.stderr.strip()} stdout={submit.stdout.strip()}" - ) - if "Request Status: complete" not in submit.stdout: - raise RuntimeError( - f"cert request not complete: {submit.stdout.strip()}" - ) - m = _CERT_ID_RE.search(submit.stdout) - if not m: - raise RuntimeError(f"no Certificate ID in output: {submit.stdout.strip()}") - cert_id = m.group(1) - - show = subprocess.run( - _pki_base() + [ - "ca-cert-show", cert_id, - "--output", cert_path, - ], - capture_output=True, - text=True, - ) - if show.returncode != 0: - raise RuntimeError( - f"pki ca-cert-show failed: rc={show.returncode} stderr={show.stderr.strip()}" - ) - with open(cert_path, "rb") as f: - return f.read().decode("ascii") - - -async def submit_csr(csr_pem: bytes) -> str: - """Async wrapper — runs the blocking pki CLI in a thread.""" - return await asyncio.to_thread(_sync_submit_csr, csr_pem) diff --git a/deployment/files/wrpac-acme/wrpac_acme/facade.py b/deployment/files/wrpac-acme/wrpac_acme/facade.py deleted file mode 100644 index 4c3f7022..00000000 --- a/deployment/files/wrpac-acme/wrpac_acme/facade.py +++ /dev/null @@ -1,574 +0,0 @@ -"""WE BUILD CS-06 ACME façade — minimal interop-test implementation. - -What's here ------------ -* /acme-eudi-wrpac/directory with `wrp-id` and `registrar-api-01` in meta -* JWS-signed requests per RFC 8555 §6 -* newAccount with EAB carrying a (simulated) EBWOID {id, name} claim - (CS-06 §5.4 MVP path — EAB blob required, HMAC not verified) -* newOrder accepting `{type:"wrp-id", value: }` -* registrar-api-01 challenge: fetches RP List, compares key-authorization, - and enforces the EBWOID↔wrp-id match (CS-06 §7.2 #9: EBWOID.id == wrp-id, - EBWOID.name == RP List legal name) -* finalize: verifies CSR Subject DN matches the RP List entry, forwards CSR to - Dogtag (`pki ca-cert-request-submit --profile wrpacCert`), returns issued PEM - -What's deliberately out of scope (see deployment/README.md) ----------------------------------------------------------- -* HTTPS, nonce replay protection, real EAB HMAC, keyChange, multi-instance, - registrationCertificate co-issuance, CT logging -""" -from __future__ import annotations - -import json -import os -import secrets -import uuid -from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional - -import httpx -from cryptography import x509 -from cryptography.hazmat.primitives.serialization import Encoding -from fastapi import FastAPI, HTTPException, Request, Response -from fastapi.responses import JSONResponse, PlainTextResponse -from jwcrypto import jwk - -from .crypto import key_authorization, parse_and_verify -from .dogtag import submit_csr -from .storage import Store - -ROOT = "/acme-eudi-wrpac" -RP_LIST_URL = os.getenv("WRPAC_RP_LIST_URL", "http://localhost:9000") -DB_PATH = os.getenv("WRPAC_DB_PATH", "/var/lib/wrpac-acme/facade.sqlite3") -# Optional fallback if the request doesn't carry usable Host/X-Forwarded-*. -BASE_URL_FALLBACK = os.getenv("WRPAC_BASE_URL", "http://mw-vm-ca01.lab:9080").rstrip("/") - -app = FastAPI(title="WE BUILD CS-06 ACME façade", version="0.1.0") - - -# ---------- Persistent state ---------- - -@dataclass -class Account: - id: str - jwk: jwk.JWK - contact: List[str] = field(default_factory=list) - status: str = "valid" - # Organisational identity from the EBWOID the RA verified (CS-06 §5.4). - # Matched against the order's wrp-id at challenge time (§7.2 #9). - ebwoid_id: Optional[str] = None - ebwoid_name: Optional[str] = None - - -@dataclass -class Challenge: - id: str - type: str # "registrar-api-01" - token: str - status: str = "pending" - authz_id: str = "" - - -@dataclass -class Authorization: - id: str - order_id: str - identifier_type: str - identifier_value: str - status: str = "pending" - challenge_ids: List[str] = field(default_factory=list) - - -@dataclass -class Order: - id: str - account_id: str - identifiers: List[Dict[str, str]] - authz_ids: List[str] = field(default_factory=list) - status: str = "pending" - cert_id: Optional[str] = None - - -# Serialization helpers --------------------------------------------------- - -def _account_to_dict(a: Account) -> dict: - return { - "id": a.id, - "jwk": json.loads(a.jwk.export(private_key=False)), - "contact": list(a.contact), - "status": a.status, - "ebwoid_id": a.ebwoid_id, - "ebwoid_name": a.ebwoid_name, - } - - -def _account_from_dict(d: dict) -> Account: - return Account( - id=d["id"], - jwk=jwk.JWK(**d["jwk"]), - contact=list(d.get("contact", [])), - status=d.get("status", "valid"), - ebwoid_id=d.get("ebwoid_id"), - ebwoid_name=d.get("ebwoid_name"), - ) - - -def _challenge_to_dict(c: Challenge) -> dict: - return {"id": c.id, "type": c.type, "token": c.token, - "status": c.status, "authz_id": c.authz_id} - - -def _challenge_from_dict(d: dict) -> Challenge: - return Challenge(**d) - - -def _authz_to_dict(a: Authorization) -> dict: - return {"id": a.id, "order_id": a.order_id, - "identifier_type": a.identifier_type, - "identifier_value": a.identifier_value, - "status": a.status, - "challenge_ids": list(a.challenge_ids)} - - -def _authz_from_dict(d: dict) -> Authorization: - return Authorization(**d) - - -def _order_to_dict(o: Order) -> dict: - return {"id": o.id, "account_id": o.account_id, - "identifiers": list(o.identifiers), - "authz_ids": list(o.authz_ids), - "status": o.status, "cert_id": o.cert_id} - - -def _order_from_dict(d: dict) -> Order: - return Order(**d) - - -def _cert_to_dict(pem: str) -> dict: - return {"pem": pem} - - -def _cert_from_dict(d: dict) -> str: - return d["pem"] - - -_accounts: Store[Account] = Store(DB_PATH, "accounts", _account_to_dict, _account_from_dict) -_orders: Store[Order] = Store(DB_PATH, "orders", _order_to_dict, _order_from_dict) -_authzs: Store[Authorization] = Store(DB_PATH, "authzs", _authz_to_dict, _authz_from_dict) -_challenges: Store[Challenge] = Store(DB_PATH, "challenges", _challenge_to_dict, _challenge_from_dict) -_certs: Store[str] = Store(DB_PATH, "certs", _cert_to_dict, _cert_from_dict) - - -# ---------- URL helpers ---------- - -def base_url(request: Optional[Request]) -> str: - """Derive the public-facing base URL from the request. - - Honors X-Forwarded-Proto / Host when uvicorn is started with - --proxy-headers; otherwise reads scheme+host directly. Falls back to - BASE_URL_FALLBACK when no request is available (e.g. internal helpers - that don't have a request handle yet).""" - if request is None: - return BASE_URL_FALLBACK - return f"{request.url.scheme}://{request.url.netloc}".rstrip("/") - - -def u(request: Optional[Request], *path: str) -> str: - return f"{base_url(request)}{ROOT}/{'/'.join(path)}" - - -# ---------- Middleware: stamp every response with Replay-Nonce ---------- - -@app.middleware("http") -async def stamp_nonce(request: Request, call_next): - response = await call_next(request) - # Every ACME response should carry a fresh nonce (RFC 8555 §6.5). - response.headers["Replay-Nonce"] = secrets.token_urlsafe(24) - return response - - -# ---------- Directory & nonce ---------- - -@app.get(ROOT + "/directory") -def directory(request: Request) -> JSONResponse: - return JSONResponse({ - "newNonce": u(request, "new-nonce"), - "newAccount": u(request, "new-account"), - "newOrder": u(request, "new-order"), - "revokeCert": u(request, "revoke-cert"), - "meta": { - "termsOfService": f"{base_url(request)}/tos", - "externalAccountRequired": True, - "supportedIdentifierTypes": ["wrp-id"], - "supportedChallengeTypes": ["registrar-api-01"], - "rprcCoIssuanceSupported": False, - "multiInstanceIssuanceSupported": False, - }, - }) - - -@app.api_route(ROOT + "/new-nonce", methods=["GET", "HEAD"]) -def new_nonce() -> Response: - # The middleware adds the Replay-Nonce header for us. - # RFC 8555 §7.2: HEAD returns 200, GET returns 204. - return Response(status_code=204) - - -# ---------- JWS helpers ---------- - -async def _read_jws(request: Request) -> dict: - raw = await request.body() - try: - return __import__("json").loads(raw.decode("utf-8")) - except Exception: - raise HTTPException(400, "request body must be a flattened JWS") - - -def _verify_with_account(body: dict) -> tuple[Account, dict, bytes]: - """Verify a JWS where `kid` references an existing account URL.""" - import base64 - import json as _json - - if "protected" not in body: - raise HTTPException(400, "missing protected header") - pad = "=" * (-len(body["protected"]) % 4) - protected = _json.loads(base64.urlsafe_b64decode(body["protected"] + pad)) - kid = protected.get("kid") - if not kid: - raise HTTPException(400, "kid required for this endpoint") - # kid is the account URL: .../acct/ - account_id = kid.rstrip("/").rsplit("/", 1)[-1] - account = _accounts.get(account_id) - if account is None: - raise HTTPException(401, "unknown account") - parsed = parse_and_verify(body, account_jwk=account.jwk) - return account, parsed.protected, parsed.payload - - -# ---------- newAccount ---------- - -def _eab_ebwoid(eab: dict) -> dict: - """Extract the (simulated) EBWOID {id, name} claim from the EAB protected - header. In production the RA resolves this from the EAB Key ID after - verifying the EBWOID via OID4VP; in this MVP the claim travels in the EAB - so the façade can enforce the EBWOID↔wrp-id match (CS-06 §5.4, §7.2 #9).""" - import base64 - import json as _json - try: - prot = eab["protected"] - pad = "=" * (-len(prot) % 4) - header = _json.loads(base64.urlsafe_b64decode(prot + pad)) - except Exception: - return {} - ebwoid = header.get("ebwoid") - return ebwoid if isinstance(ebwoid, dict) else {} - - -@app.post(ROOT + "/new-account") -async def new_account(request: Request) -> JSONResponse: - body = await _read_jws(request) - parsed = parse_and_verify(body, account_jwk=None) - payload = parsed.payload_json or {} - - # CS-06 §5.4: EAB required. In production the RA issues these credentials - # bound to the organisation's verified EBWOID; here the EAB carries the - # (simulated) EBWOID identity {id, name} in its protected header so the - # façade can enforce the EBWOID↔wrp-id match at challenge time (§7.2 #9). - eab = payload.get("externalAccountBinding") - if not eab or not all(k in eab for k in ("protected", "payload", "signature")): - raise HTTPException(400, "externalAccountBinding required (CS-06 §5.4)") - ebwoid = _eab_ebwoid(eab) - if not ebwoid.get("id") or not ebwoid.get("name"): - raise HTTPException( - 400, - "externalAccountBinding must carry an EBWOID {id, name} claim (CS-06 §5.4)", - ) - - account_id = uuid.uuid4().hex[:16] - _accounts[account_id] = Account( - id=account_id, - jwk=parsed.jwk_obj, - contact=payload.get("contact", []), - ebwoid_id=ebwoid["id"], - ebwoid_name=ebwoid["name"], - ) - resp = JSONResponse( - { - "status": "valid", - "contact": _accounts[account_id].contact, - "orders": u(request, "acct", account_id, "orders"), - }, - status_code=201, - ) - resp.headers["Location"] = u(request, "acct", account_id) - return resp - - -# ---------- newOrder ---------- - -@app.post(ROOT + "/new-order") -async def new_order(request: Request) -> JSONResponse: - body = await _read_jws(request) - account, _, _payload = _verify_with_account(body) - payload_json = __import__("json").loads(_payload.decode("utf-8")) if _payload else {} - - identifiers = payload_json.get("identifiers") or [] - if not identifiers or any(i.get("type") != "wrp-id" for i in identifiers): - raise HTTPException(400, "identifiers must contain at least one wrp-id (CS-06 §5.2)") - - order_id = uuid.uuid4().hex[:16] - authz_ids: List[str] = [] - for ident in identifiers: - authz_id = uuid.uuid4().hex[:16] - chall_id = uuid.uuid4().hex[:16] - token = secrets.token_urlsafe(24) - _challenges[chall_id] = Challenge( - id=chall_id, type="registrar-api-01", token=token, authz_id=authz_id, - ) - _authzs[authz_id] = Authorization( - id=authz_id, - order_id=order_id, - identifier_type=ident["type"], - identifier_value=ident["value"], - challenge_ids=[chall_id], - ) - authz_ids.append(authz_id) - - _orders[order_id] = Order( - id=order_id, - account_id=account.id, - identifiers=identifiers, - authz_ids=authz_ids, - ) - resp = JSONResponse(_order_view(request, order_id), status_code=201) - resp.headers["Location"] = u(request, "order", order_id) - return resp - - -def _order_view(request: Request, order_id: str) -> dict: - o = _orders[order_id] - view = { - "status": o.status, - "identifiers": o.identifiers, - "authorizations": [u(request, "authz", a) for a in o.authz_ids], - "finalize": u(request, "order", order_id, "finalize"), - } - if o.cert_id: - view["certificate"] = u(request, "cert", o.cert_id) - return view - - -# ---------- Order / Authz / Challenge GET (POST-as-GET) ---------- - -@app.post(ROOT + "/order/{order_id}") -async def order(order_id: str, request: Request) -> JSONResponse: - body = await _read_jws(request) - _verify_with_account(body) - if order_id not in _orders: - raise HTTPException(404, "order not found") - return JSONResponse(_order_view(request, order_id)) - - -@app.post(ROOT + "/authz/{authz_id}") -async def authz(authz_id: str, request: Request) -> JSONResponse: - body = await _read_jws(request) - _verify_with_account(body) - if authz_id not in _authzs: - raise HTTPException(404, "authz not found") - return JSONResponse(_authz_view(request, authz_id)) - - -def _authz_view(request: Request, authz_id: str) -> dict: - a = _authzs[authz_id] - return { - "status": a.status, - "identifier": {"type": a.identifier_type, "value": a.identifier_value}, - "challenges": [_challenge_view(request, c) for c in a.challenge_ids], - } - - -def _challenge_view(request: Request, chall_id: str) -> dict: - c = _challenges[chall_id] - return { - "type": c.type, - "url": u(request, "chall", c.id), - "status": c.status, - "token": c.token, - } - - -# ---------- registrar-api-01 challenge validation ---------- - -@app.post(ROOT + "/chall/{chall_id}") -async def challenge(chall_id: str, request: Request) -> JSONResponse: - body = await _read_jws(request) - account, _, payload = _verify_with_account(body) - if chall_id not in _challenges: - raise HTTPException(404, "challenge not found") - c = _challenges[chall_id] - a = _authzs[c.authz_id] - - # Empty payload "{}" signals readiness (RFC 8555 §7.5.1). - if payload and payload != b"{}": - raise HTTPException(400, "challenge POST payload must be empty object") - - # Compute expected key-authorization - expected = key_authorization(c.token, account.jwk) - - # Fetch the RP List entry for this WRP - async with httpx.AsyncClient(timeout=5.0) as client: - r = await client.get(f"{RP_LIST_URL}/rp-list/{a.identifier_value}") - if r.status_code != 200: - c.status = "invalid" - a.status = "invalid" - _challenges.save(c.id); _authzs.save(a.id) - raise HTTPException(403, f"WRP not in RP List: {a.identifier_value}") - entry = r.json() - if entry.get("status") != "active": - c.status = "invalid" - a.status = "invalid" - _challenges.save(c.id); _authzs.save(a.id) - raise HTTPException(403, "WRP not active") - - # CS-06 §7.2 #9: the EBWOID bound to the account MUST correspond to the WRP - # being ordered — EBWOID.id == wrp-id and EBWOID.name == the RP List entry's - # WRP legal name. This is the direct, testable identity binding that replaces - # the looser POA-based check. - if (account.ebwoid_id != a.identifier_value - or account.ebwoid_name != entry.get("legal_name")): - c.status = "invalid" - a.status = "invalid" - _challenges.save(c.id); _authzs.save(a.id) - raise HTTPException( - 403, - "EBWOID does not correspond to the WRP: " - f"ebwoid.id={account.ebwoid_id!r} wrp-id={a.identifier_value!r}, " - f"ebwoid.name={account.ebwoid_name!r} legal_name={entry.get('legal_name')!r}", - ) - - # acme-challenge may be a bare string (single-instance shorthand) or a - # structured object per CS-06 §7.8. We only support single-instance. - chal = entry.get("acme_challenge") - if isinstance(chal, dict): - chal = chal.get("default") - if chal != expected: - c.status = "invalid" - a.status = "invalid" - _challenges.save(c.id); _authzs.save(a.id) - raise HTTPException( - 403, - "RP List acme-challenge does not match expected key-authorization", - ) - - c.status = "valid" - a.status = "valid" - _challenges.save(c.id); _authzs.save(a.id) - - # Move order toward `ready` if all authz are valid - o = _orders[a.order_id] - if all(_authzs[az].status == "valid" for az in o.authz_ids): - o.status = "ready" - _orders.save(o.id) - - return JSONResponse(_challenge_view(request, chall_id)) - - -# ---------- finalize ---------- - -@app.post(ROOT + "/order/{order_id}/finalize") -async def finalize(order_id: str, request: Request) -> JSONResponse: - body = await _read_jws(request) - account, _, payload = _verify_with_account(body) - if order_id not in _orders: - raise HTTPException(404, "order not found") - o = _orders[order_id] - if o.account_id != account.id: - raise HTTPException(403, "order belongs to another account") - if o.status != "ready": - raise HTTPException(403, f"order not ready (status={o.status})") - - payload_json = __import__("json").loads(payload.decode("utf-8")) if payload else {} - csr_b64u = payload_json.get("csr") - if not csr_b64u: - raise HTTPException(400, "csr field required") - - import base64 - pad = "=" * (-len(csr_b64u) % 4) - csr_der = base64.urlsafe_b64decode(csr_b64u + pad) - csr = x509.load_der_x509_csr(csr_der) - - # Cross-check CSR Subject matches the RP List entry for the order's WRP - wrp_id = o.identifiers[0]["value"] - async with httpx.AsyncClient(timeout=5.0) as client: - r = await client.get(f"{RP_LIST_URL}/rp-list/{wrp_id}") - if r.status_code != 200: - raise HTTPException(403, "WRP not in RP List at finalize time") - entry = r.json() - - subject_attrs = {a.oid.dotted_string: a.value for a in csr.subject} - issues = [] - if subject_attrs.get("2.5.4.97") != entry["organization_identifier"]: - issues.append( - f"organizationIdentifier mismatch: csr={subject_attrs.get('2.5.4.97')!r} " - f"rp-list={entry['organization_identifier']!r}" - ) - if subject_attrs.get("2.5.4.10") != entry["legal_name"]: - issues.append( - f"O mismatch: csr={subject_attrs.get('2.5.4.10')!r} " - f"rp-list={entry['legal_name']!r}" - ) - if subject_attrs.get("2.5.4.6") != entry["country"]: - issues.append( - f"C mismatch: csr={subject_attrs.get('2.5.4.6')!r} " - f"rp-list={entry['country']!r}" - ) - if issues: - raise HTTPException( - 400, - "CSR Subject does not match RP List entry: " + "; ".join(issues), - ) - - o.status = "processing" - _orders.save(o.id) - - # Forward to Dogtag - csr_pem = csr.public_bytes(Encoding.PEM) - try: - cert_pem = await submit_csr(csr_pem) - except Exception as exc: - o.status = "invalid" - _orders.save(o.id) - raise HTTPException(500, f"CA issuance failed: {exc}") - - cert_id = uuid.uuid4().hex[:16] - _certs[cert_id] = cert_pem - o.cert_id = cert_id - o.status = "valid" - _orders.save(o.id) - return JSONResponse(_order_view(request, order_id)) - - -# ---------- cert download ---------- - -@app.post(ROOT + "/cert/{cert_id}") -async def cert(cert_id: str, request: Request) -> Response: - body = await _read_jws(request) - _verify_with_account(body) - if cert_id not in _certs: - raise HTTPException(404, "cert not found") - return PlainTextResponse( - _certs[cert_id], - media_type="application/pem-certificate-chain", - ) - - -@app.post(ROOT + "/revoke-cert") -async def revoke_cert(request: Request) -> Response: - raise HTTPException(501, "revocation not implemented in Phase 3 MVP") - - -@app.get("/tos") -def tos() -> PlainTextResponse: - return PlainTextResponse("WE BUILD interoperability testing only.") diff --git a/deployment/files/wrpac-acme/wrpac_acme/negative_tests.py b/deployment/files/wrpac-acme/wrpac_acme/negative_tests.py deleted file mode 100644 index 2a6efef5..00000000 --- a/deployment/files/wrpac-acme/wrpac_acme/negative_tests.py +++ /dev/null @@ -1,263 +0,0 @@ -"""Negative tests for the CS-06 façade. - -Exercises CS-06 §7.2 MUST clauses by trying things the façade must reject. - -Run: - python -m wrpac_acme.negative_tests \ - --directory https://rpca.lab.cleverbase.io/acme-eudi-wrpac/directory \ - --rp-list https://rpca.lab.cleverbase.io -""" -from __future__ import annotations - -import argparse -import base64 -import json -import sys -from typing import Tuple - -import httpx -from cryptography import x509 -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import rsa -from cryptography.x509.oid import NameOID -from jwcrypto import jwk, jws - - -def b64u(b: bytes) -> str: - return base64.urlsafe_b64encode(b).rstrip(b"=").decode("ascii") - - -def sign(key: jwk.JWK, protected: dict, payload: bytes) -> dict: - tok = jws.JWS(payload) - tok.add_signature(key, alg=protected.get("alg", "RS256"), - protected=json.dumps(protected)) - return json.loads(tok.serialize()) - - -def pass_(name: str) -> None: - print(f" PASS {name}") - - -def fail(name: str, why: str) -> None: - print(f" FAIL {name} — {why}") - - -class Runner: - def __init__(self, directory_url: str, rp_list_url: str) -> None: - self.directory_url = directory_url - self.rp_list_url = rp_list_url.rstrip("/") - self.http = httpx.Client(timeout=15.0) - self.dir: dict = {} - self.fails = 0 - self.passes = 0 - - def record(self, ok: bool, name: str, why: str = "") -> None: - if ok: - self.passes += 1 - pass_(name) - else: - self.fails += 1 - fail(name, why) - - def setup(self) -> None: - self.dir = self.http.get(self.directory_url).json() - - def fresh_nonce(self) -> str: - return self.http.head(self.dir["newNonce"]).headers["Replay-Nonce"] - - def fresh_account(self, ebwoid_id: str = "NLKVK.12345678", - ebwoid_name: str = "ACME Test Organisation B.V.", - ) -> Tuple[jwk.JWK, str]: - """Successfully create a new account and return (key, account_url). - - The EAB carries a (simulated) EBWOID {id, name}; the defaults match the - seeded WRP so the account passes the §7.2 #9 EBWOID↔wrp-id check.""" - key = jwk.JWK.generate(kty="RSA", size=2048) - nonce = self.fresh_nonce() - eab_protected = b64u(json.dumps({"alg": "HS256", "kid": "eab-test", - "url": self.dir["newAccount"], - "ebwoid": {"id": ebwoid_id, - "name": ebwoid_name}}).encode()) - eab_payload = b64u(key.export_public().encode()) - eab_sig = b64u(b"STUB") - payload = { - "termsOfServiceAgreed": True, - "contact": ["mailto:neg@test"], - "externalAccountBinding": { - "protected": eab_protected, - "payload": eab_payload, - "signature": eab_sig, - }, - } - protected = {"alg": "RS256", "nonce": nonce, "url": self.dir["newAccount"], - "jwk": json.loads(key.export_public())} - r = self.http.post(self.dir["newAccount"], - json=sign(key, protected, - json.dumps(payload).encode()), - headers={"Content-Type": "application/jose+json"}) - return key, r.headers["Location"] - - def post(self, url: str, key: jwk.JWK, kid: str, - payload: dict | None) -> httpx.Response: - nonce = self.fresh_nonce() - protected = {"alg": "RS256", "nonce": nonce, "url": url, "kid": kid} - body = sign(key, protected, - b"" if payload is None else json.dumps(payload).encode()) - return self.http.post(url, json=body, - headers={"Content-Type": "application/jose+json"}) - - # ---- tests ---- - - def t_newaccount_without_eab(self) -> None: - name = "newAccount without externalAccountBinding rejected (§7.2 #2)" - key = jwk.JWK.generate(kty="RSA", size=2048) - nonce = self.fresh_nonce() - protected = {"alg": "RS256", "nonce": nonce, "url": self.dir["newAccount"], - "jwk": json.loads(key.export_public())} - payload = {"termsOfServiceAgreed": True, "contact": ["mailto:x@y"]} - r = self.http.post(self.dir["newAccount"], - json=sign(key, protected, - json.dumps(payload).encode()), - headers={"Content-Type": "application/jose+json"}) - self.record(r.status_code == 400, name, - f"expected 400, got {r.status_code}: {r.text[:200]}") - - def t_neworder_bad_identifier_type(self) -> None: - name = "newOrder rejects non-wrp-id identifier (§7.2 #3)" - key, acct = self.fresh_account() - r = self.post(self.dir["newOrder"], key, acct, - {"identifiers": [{"type": "dns", "value": "example.com"}]}) - self.record(r.status_code == 400, name, - f"expected 400, got {r.status_code}: {r.text[:200]}") - - def t_challenge_unknown_wrp(self) -> None: - name = "registrar-api-01 fails for wrp-id not in RP List (§7.2 #4,5)" - key, acct = self.fresh_account() - r = self.post(self.dir["newOrder"], key, acct, - {"identifiers": [{"type": "wrp-id", - "value": "NLKVK.UNKNOWN-9999"}]}) - if r.status_code not in (200, 201): - self.record(False, name, f"newOrder failed unexpectedly: {r.status_code}") - return - order = r.json() - authz = self.post(order["authorizations"][0], key, acct, None).json() - chall_url = next(c["url"] for c in authz["challenges"] - if c["type"] == "registrar-api-01") - # Skip placing key-auth — server will fetch from RP List and 404 - r = self.post(chall_url, key, acct, {}) - self.record(r.status_code == 403, name, - f"expected 403, got {r.status_code}: {r.text[:200]}") - - def t_challenge_wrong_keyauth(self) -> None: - name = "registrar-api-01 fails when RP List acme-challenge mismatches (§7.2 #4,5)" - key, acct = self.fresh_account() - r = self.post(self.dir["newOrder"], key, acct, - {"identifiers": [{"type": "wrp-id", - "value": "NLKVK.12345678"}]}) - order = r.json() - authz = self.post(order["authorizations"][0], key, acct, None).json() - chall = next(c for c in authz["challenges"] - if c["type"] == "registrar-api-01") - # Put a deliberately WRONG key-auth in the RP List - wrong = chall["token"] + ".DELIBERATELY-WRONG-THUMBPRINT" - self.http.put(f"{self.rp_list_url}/rp-list/NLKVK.12345678/acme-challenge", - json={"key_authorization": wrong}) - r = self.post(chall["url"], key, acct, {}) - self.record(r.status_code == 403, name, - f"expected 403, got {r.status_code}: {r.text[:200]}") - - def t_challenge_ebwoid_mismatch(self) -> None: - name = "registrar-api-01 fails when EBWOID.id != ordered wrp-id (§7.2 #9)" - # Account's EBWOID is for a DIFFERENT organisation than the WRP ordered. - key, acct = self.fresh_account(ebwoid_id="NLKVK.99999999", - ebwoid_name="Someone Else B.V.") - r = self.post(self.dir["newOrder"], key, acct, - {"identifiers": [{"type": "wrp-id", - "value": "NLKVK.12345678"}]}) - if r.status_code not in (200, 201): - self.record(False, name, f"newOrder failed unexpectedly: {r.status_code}") - return - order = r.json() - authz = self.post(order["authorizations"][0], key, acct, None).json() - chall = next(c for c in authz["challenges"] - if c["type"] == "registrar-api-01") - # Place the CORRECT key-auth, so the only failing check is the EBWOID match. - key_auth = f"{chall['token']}.{key.thumbprint()}" - self.http.put(f"{self.rp_list_url}/rp-list/NLKVK.12345678/acme-challenge", - json={"key_authorization": key_auth}) - r = self.post(chall["url"], key, acct, {}) - self.record(r.status_code == 403, name, - f"expected 403, got {r.status_code}: {r.text[:200]}") - - def t_finalize_subject_mismatch(self) -> None: - name = "finalize rejects CSR whose Subject doesn't match RP List entry" - key, acct = self.fresh_account() - # newOrder - r = self.post(self.dir["newOrder"], key, acct, - {"identifiers": [{"type": "wrp-id", - "value": "NLKVK.12345678"}]}) - order = r.json() - order_url = r.headers["Location"] - # complete challenge with CORRECT key-auth - authz = self.post(order["authorizations"][0], key, acct, None).json() - chall = next(c for c in authz["challenges"] - if c["type"] == "registrar-api-01") - key_auth = f"{chall['token']}.{key.thumbprint()}" - self.http.put(f"{self.rp_list_url}/rp-list/NLKVK.12345678/acme-challenge", - json={"key_authorization": key_auth}) - self.post(chall["url"], key, acct, {}) - # Build CSR with WRONG O / organizationIdentifier - cert_key = rsa.generate_private_key(public_exponent=65537, key_size=3072) - wrong_subject = x509.Name([ - x509.NameAttribute(NameOID.COUNTRY_NAME, "DE"), - x509.NameAttribute(x509.ObjectIdentifier("2.5.4.97"), - "VAT-WRONG-IDENTIFIER"), - x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Wrong Org GmbH"), - x509.NameAttribute(NameOID.COMMON_NAME, "Wrong friendly name"), - ]) - csr = (x509.CertificateSigningRequestBuilder() - .subject_name(wrong_subject) - .sign(cert_key, hashes.SHA256())) - csr_der = csr.public_bytes(serialization.Encoding.DER) - r = self.post(order["finalize"], key, acct, {"csr": b64u(csr_der)}) - self.record(r.status_code == 400, name, - f"expected 400, got {r.status_code}: {r.text[:200]}") - - def t_revoke_unimplemented(self) -> None: - name = "revokeCert returns 501 (stub — known gap)" - key, acct = self.fresh_account() - r = self.post(self.dir["revokeCert"], key, acct, {"certificate": "x"}) - self.record(r.status_code == 501, name, - f"expected 501, got {r.status_code}: {r.text[:200]}") - - -def main() -> int: - ap = argparse.ArgumentParser() - ap.add_argument("--directory", required=True) - ap.add_argument("--rp-list", required=True) - args = ap.parse_args() - r = Runner(args.directory, args.rp_list) - r.setup() - print("CS-06 negative-test battery") - print("=" * 60) - for t in ( - r.t_newaccount_without_eab, - r.t_neworder_bad_identifier_type, - r.t_challenge_unknown_wrp, - r.t_challenge_wrong_keyauth, - r.t_challenge_ebwoid_mismatch, - r.t_finalize_subject_mismatch, - r.t_revoke_unimplemented, - ): - try: - t() - except Exception as exc: - r.fails += 1 - fail(t.__name__, f"exception: {exc}") - print("=" * 60) - print(f" {r.passes} passed, {r.fails} failed") - return 0 if r.fails == 0 else 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/deployment/files/wrpac-acme/wrpac_acme/rp_list.py b/deployment/files/wrpac-acme/wrpac_acme/rp_list.py deleted file mode 100644 index 4d13584f..00000000 --- a/deployment/files/wrpac-acme/wrpac_acme/rp_list.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Mock Registrar / RP List service per CS-06 §7.8. - -Stand-in for the national Registrar's API in the WE BUILD pilot. - -Endpoints: - GET /rp-list/{wrp_id} — return the RP entry - PUT /rp-list/{wrp_id} — admin: create / update entry - PUT /rp-list/{wrp_id}/acme-challenge — WRP places its key-authorization - DELETE /rp-list/{wrp_id} — admin - GET /rp-list — list all WRPs - -In production these would be queried per EC TS5 / TS6. -""" -from __future__ import annotations - -import os -from typing import Any, Dict, Optional, Union - -from fastapi import FastAPI, HTTPException -from pydantic import BaseModel, Field - -from .storage import Store - -app = FastAPI(title="WE BUILD Mock RP List", version="0.1.0") - -DB_PATH = os.getenv("WRPAC_RP_LIST_DB_PATH", "/var/lib/wrpac-acme/rp_list.sqlite3") - - -class ACMEChallenge(BaseModel): - """CS-06 §7.8 allows both a single key-authorization string and a - multi-instance dict keyed by `instanceId`. We accept both.""" - - default: Optional[str] = None - instances: Dict[str, str] = Field(default_factory=dict) - - -class RPListEntry(BaseModel): - wrp_id: str - legal_name: str - organization_identifier: str - country: str = "NL" - status: str = "active" - acme_challenge: Union[str, ACMEChallenge, None] = None - - -def _entry_to_dict(e: RPListEntry) -> dict: - return e.model_dump(mode="json") - - -def _entry_from_dict(d: dict) -> RPListEntry: - return RPListEntry.model_validate(d) - - -_store: Store[RPListEntry] = Store(DB_PATH, "entries", _entry_to_dict, _entry_from_dict) - - -def _seed() -> None: - """Pre-populate one test WRP so the smoke client has something to enrol. - - Only seeds if the WRP isn't already in the persistent store — re-running - after a restart preserves the entry (including any acme-challenge values - a client has placed).""" - seed = os.getenv("WRPAC_SEED_WRP_ID", "NLKVK.12345678") - if seed in _store: - return - _store[seed] = RPListEntry( - wrp_id=seed, - legal_name=os.getenv("WRPAC_SEED_LEGAL_NAME", "ACME Test Organisation B.V."), - organization_identifier=os.getenv("WRPAC_SEED_ORG_ID", "VATNL-000000000B01"), - country=os.getenv("WRPAC_SEED_COUNTRY", "NL"), - status="active", - acme_challenge=None, - ) - - -_seed() - - -@app.get("/rp-list") -def list_all() -> Dict[str, Any]: - return {"items": [e.model_dump() for e in _store.values()]} - - -@app.get("/rp-list/{wrp_id}") -def get_entry(wrp_id: str) -> RPListEntry: - entry = _store.get(wrp_id) - if entry is None: - raise HTTPException(404, "WRP not found") - return entry - - -@app.put("/rp-list/{wrp_id}") -def put_entry(wrp_id: str, entry: RPListEntry) -> RPListEntry: - if entry.wrp_id != wrp_id: - raise HTTPException(400, "wrp_id in path/body mismatch") - _store[wrp_id] = entry - return entry - - -class ChallengePayload(BaseModel): - key_authorization: str - instance_id: Optional[str] = None - - -@app.put("/rp-list/{wrp_id}/acme-challenge") -def put_challenge(wrp_id: str, payload: ChallengePayload) -> Dict[str, Any]: - entry = _store.get(wrp_id) - if entry is None: - raise HTTPException(404, "WRP not found") - if entry.status != "active": - raise HTTPException(403, "WRP not active") - if payload.instance_id: - # Multi-instance form - if not isinstance(entry.acme_challenge, ACMEChallenge): - entry.acme_challenge = ACMEChallenge() - entry.acme_challenge.instances[payload.instance_id] = payload.key_authorization - else: - # Single-instance shorthand (CS-06 §7.8) - entry.acme_challenge = payload.key_authorization - _store[wrp_id] = entry - return {"wrp_id": wrp_id, "acme_challenge": entry.acme_challenge} - - -@app.delete("/rp-list/{wrp_id}") -def delete_entry(wrp_id: str) -> Dict[str, str]: - if wrp_id not in _store: - raise HTTPException(404, "WRP not found") - del _store[wrp_id] - return {"deleted": wrp_id} diff --git a/deployment/files/wrpac-acme/wrpac_acme/storage.py b/deployment/files/wrpac-acme/wrpac_acme/storage.py deleted file mode 100644 index 3870ae48..00000000 --- a/deployment/files/wrpac-acme/wrpac_acme/storage.py +++ /dev/null @@ -1,79 +0,0 @@ -"""SQLite-backed dict-shaped key-value store. - -All rows are loaded into memory on startup and every mutation is mirrored to -disk in WAL mode. This is the minimum needed to let the Phase 3 façade and the -mock RP List survive a process restart — explicitly not a production database -layer. - -Callers may mutate stored values *in place* (e.g. ``acct.status = "valid"``); -they must call ``store.save(key)`` afterwards so the change is persisted. -""" -from __future__ import annotations - -import json -import os -import sqlite3 -from typing import Any, Callable, Generic, Iterator, Optional, TypeVar - -V = TypeVar("V") - - -class Store(Generic[V]): - def __init__( - self, - db_path: str, - table: str, - to_dict: Callable[[V], dict], - from_dict: Callable[[dict], V], - ) -> None: - self._table = table - self._to_dict = to_dict - self._from_dict = from_dict - parent = os.path.dirname(db_path) or "." - os.makedirs(parent, exist_ok=True) - # isolation_level=None → autocommit; WAL → safe concurrent readers - self._db = sqlite3.connect(db_path, check_same_thread=False, - isolation_level=None) - self._db.execute("PRAGMA journal_mode=WAL") - self._db.execute( - f"CREATE TABLE IF NOT EXISTS {table} " - "(k TEXT PRIMARY KEY, v TEXT NOT NULL)" - ) - self._mem: dict[str, V] = {} - for k, v in self._db.execute(f"SELECT k, v FROM {table}"): - self._mem[k] = from_dict(json.loads(v)) - - def __setitem__(self, key: str, value: V) -> None: - self._mem[key] = value - self._db.execute( - f"INSERT OR REPLACE INTO {self._table} (k, v) VALUES (?, ?)", - (key, json.dumps(self._to_dict(value))), - ) - - def save(self, key: str) -> None: - """Re-persist a key after in-place mutation of its value.""" - if key not in self._mem: - raise KeyError(key) - self.__setitem__(key, self._mem[key]) - - def __getitem__(self, key: str) -> V: - return self._mem[key] - - def __delitem__(self, key: str) -> None: - del self._mem[key] - self._db.execute(f"DELETE FROM {self._table} WHERE k = ?", (key,)) - - def __contains__(self, key: object) -> bool: - return key in self._mem - - def __iter__(self) -> Iterator[str]: - return iter(self._mem) - - def __len__(self) -> int: - return len(self._mem) - - def get(self, key: str, default: Any = None) -> Optional[V]: - return self._mem.get(key, default) - - def values(self) -> Iterator[V]: - return iter(self._mem.values()) diff --git a/deployment/files/wrpacCert.cfg.j2 b/deployment/files/wrpacCert.cfg.j2 deleted file mode 100644 index e665900f..00000000 --- a/deployment/files/wrpacCert.cfg.j2 +++ /dev/null @@ -1,141 +0,0 @@ -profileId={{ wrpac_profile_id }} -classId=caEnrollImpl -desc=WE BUILD Wallet-Relying Party Access Certificate (CS-06) -visible=true -enable=true -enableBy=admin -auth.instance_id=AgentCertAuth -authz.acl=group=Certificate Manager Agents -name=WE BUILD WRPAC Enrollment -input.list=i1,i2 -input.i1.class_id=certReqInputImpl -input.i2.class_id=submitterInfoInputImpl -output.list=o1 -output.o1.class_id=certOutputImpl -policyset.list=wrpacSet -policyset.wrpacSet.list=1,2,3,4,5,6,7,8,9,10,11,12 - -# 1. Subject DN — taken from the CSR (allows organizationIdentifier, O, CN, C) -# CS-06 §7.4 #3,#4,#5,#9,#10 require Subject DN attributes per ETSI EN 319 412-1 -# and TS 119 475 clause 5. Pattern enforces that organizationIdentifier is present. -policyset.wrpacSet.1.constraint.class_id=subjectNameConstraintImpl -policyset.wrpacSet.1.constraint.name=Subject Name Constraint -policyset.wrpacSet.1.constraint.params.pattern=.*2\.5\.4\.97=.+ -policyset.wrpacSet.1.constraint.params.accept=true -policyset.wrpacSet.1.default.class_id=userSubjectNameDefaultImpl -policyset.wrpacSet.1.default.name=User Supplied Subject Name Default - -# 2. Validity — 1 year (CS-06 §7.4 #13) -policyset.wrpacSet.2.constraint.class_id=validityConstraintImpl -policyset.wrpacSet.2.constraint.name=Validity Constraint -policyset.wrpacSet.2.constraint.params.range={{ wrpac_validity_days }} -policyset.wrpacSet.2.constraint.params.notBeforeCheck=false -policyset.wrpacSet.2.constraint.params.notAfterCheck=false -policyset.wrpacSet.2.default.class_id=validityDefaultImpl -policyset.wrpacSet.2.default.name=Validity Default -policyset.wrpacSet.2.default.params.range={{ wrpac_validity_days }} -policyset.wrpacSet.2.default.params.startTime=0 - -# 3. Key Usage — digitalSignature + keyEncipherment, critical -policyset.wrpacSet.3.constraint.class_id=keyUsageExtConstraintImpl -policyset.wrpacSet.3.constraint.name=Key Usage Extension Constraint -policyset.wrpacSet.3.constraint.params.keyUsageCritical=true -policyset.wrpacSet.3.constraint.params.keyUsageDigitalSignature=true -policyset.wrpacSet.3.constraint.params.keyUsageNonRepudiation=false -policyset.wrpacSet.3.constraint.params.keyUsageDataEncipherment=false -policyset.wrpacSet.3.constraint.params.keyUsageKeyEncipherment=true -policyset.wrpacSet.3.constraint.params.keyUsageKeyAgreement=false -policyset.wrpacSet.3.constraint.params.keyUsageKeyCertSign=false -policyset.wrpacSet.3.constraint.params.keyUsageCrlSign=false -policyset.wrpacSet.3.constraint.params.keyUsageEncipherOnly=false -policyset.wrpacSet.3.constraint.params.keyUsageDecipherOnly=false -policyset.wrpacSet.3.default.class_id=keyUsageExtDefaultImpl -policyset.wrpacSet.3.default.name=Key Usage Default -policyset.wrpacSet.3.default.params.keyUsageCritical=true -policyset.wrpacSet.3.default.params.keyUsageDigitalSignature=true -policyset.wrpacSet.3.default.params.keyUsageNonRepudiation=false -policyset.wrpacSet.3.default.params.keyUsageDataEncipherment=false -policyset.wrpacSet.3.default.params.keyUsageKeyEncipherment=true -policyset.wrpacSet.3.default.params.keyUsageKeyAgreement=false -policyset.wrpacSet.3.default.params.keyUsageKeyCertSign=false -policyset.wrpacSet.3.default.params.keyUsageCrlSign=false -policyset.wrpacSet.3.default.params.keyUsageEncipherOnly=false -policyset.wrpacSet.3.default.params.keyUsageDecipherOnly=false - -# 4. Extended Key Usage — TLS clientAuth (RP authenticates to wallet via mTLS) -policyset.wrpacSet.4.constraint.class_id=noConstraintImpl -policyset.wrpacSet.4.constraint.name=No Constraint -policyset.wrpacSet.4.default.class_id=extendedKeyUsageExtDefaultImpl -policyset.wrpacSet.4.default.name=Extended Key Usage Extension Default -policyset.wrpacSet.4.default.params.exKeyUsageCritical=false -policyset.wrpacSet.4.default.params.exKeyUsageOIDs=1.3.6.1.5.5.7.3.2,1.3.6.1.5.5.7.3.1 - -# 5. Basic Constraints — CA=false, critical -policyset.wrpacSet.5.constraint.class_id=basicConstraintsExtConstraintImpl -policyset.wrpacSet.5.constraint.name=Basic Constraint Extension Constraint -policyset.wrpacSet.5.constraint.params.basicConstraintsCritical=true -policyset.wrpacSet.5.constraint.params.basicConstraintsIsCA=false -policyset.wrpacSet.5.constraint.params.basicConstraintsMinPathLen=-1 -policyset.wrpacSet.5.constraint.params.basicConstraintsMaxPathLen=-1 -policyset.wrpacSet.5.default.class_id=basicConstraintsExtDefaultImpl -policyset.wrpacSet.5.default.name=Basic Constraints Extension Default -policyset.wrpacSet.5.default.params.basicConstraintsCritical=true -policyset.wrpacSet.5.default.params.basicConstraintsIsCA=false -policyset.wrpacSet.5.default.params.basicConstraintsPathLen=-1 - -# 6. Authority Key Identifier -policyset.wrpacSet.6.constraint.class_id=noConstraintImpl -policyset.wrpacSet.6.constraint.name=No Constraint -policyset.wrpacSet.6.default.class_id=authorityKeyIdentifierExtDefaultImpl -policyset.wrpacSet.6.default.name=Authority Key Identifier Default - -# 7. AIA — CS-06 §7.4 #7 (certification path information URI) -policyset.wrpacSet.7.constraint.class_id=noConstraintImpl -policyset.wrpacSet.7.constraint.name=No Constraint -policyset.wrpacSet.7.default.class_id=authInfoAccessExtDefaultImpl -policyset.wrpacSet.7.default.name=AIA Extension Default -policyset.wrpacSet.7.default.params.authInfoAccessADEnable_0=true -policyset.wrpacSet.7.default.params.authInfoAccessADLocationType_0=URIName -policyset.wrpacSet.7.default.params.authInfoAccessADLocation_0=http://{{ ca_fqdn }}:8080/ca/ocsp -policyset.wrpacSet.7.default.params.authInfoAccessADMethod_0=1.3.6.1.5.5.7.48.1 -policyset.wrpacSet.7.default.params.authInfoAccessCritical=false -policyset.wrpacSet.7.default.params.authInfoAccessNumADs=1 - -# 8. SAN — pass through user-supplied SAN extension (CS-06 §7.4 #8) -policyset.wrpacSet.8.constraint.class_id=noConstraintImpl -policyset.wrpacSet.8.constraint.name=No Constraint -policyset.wrpacSet.8.default.class_id=userExtensionDefaultImpl -policyset.wrpacSet.8.default.name=User supplied SAN extension -policyset.wrpacSet.8.default.params.userExtOID=2.5.29.17 - -# 9. Signing algorithm — SHA-256 or stronger, RSA or ECDSA (CS-06 §7.4 #12) -policyset.wrpacSet.9.constraint.class_id=signingAlgConstraintImpl -policyset.wrpacSet.9.constraint.name=Signing Algorithm Constraint -policyset.wrpacSet.9.constraint.params.signingAlgsAllowed=SHA256withRSA,SHA384withRSA,SHA512withRSA,SHA256withEC,SHA384withEC,SHA512withEC -policyset.wrpacSet.9.default.class_id=signingAlgDefaultImpl -policyset.wrpacSet.9.default.name=Signing Algorithm Default -policyset.wrpacSet.9.default.params.signingAlg=- - -# 10. Subject Key Identifier -policyset.wrpacSet.10.constraint.class_id=noConstraintImpl -policyset.wrpacSet.10.constraint.name=No Constraint -policyset.wrpacSet.10.default.class_id=subjectKeyIdentifierExtDefaultImpl -policyset.wrpacSet.10.default.name=Subject Key Identifier Default -policyset.wrpacSet.10.default.params.critical=false - -# 11. Key Constraint — RSA 3072/4096 or ECDSA P-256/P-384 (CS-06 §7.4 #12) -policyset.wrpacSet.11.constraint.class_id=keyConstraintImpl -policyset.wrpacSet.11.constraint.name=Key Constraint -policyset.wrpacSet.11.constraint.params.keyType=- -policyset.wrpacSet.11.constraint.params.keyParameters=3072,4096,nistp256,nistp384 -policyset.wrpacSet.11.default.class_id=userKeyDefaultImpl -policyset.wrpacSet.11.default.name=Key Default - -# 12. Certificate Policies — WRPAC policy OID (CS-06 §7.4 #2,#6) -policyset.wrpacSet.12.constraint.class_id=noConstraintImpl -policyset.wrpacSet.12.constraint.name=No Constraint -policyset.wrpacSet.12.default.class_id=certificatePoliciesExtDefaultImpl -policyset.wrpacSet.12.default.name=Certificate Policies Extension Default -policyset.wrpacSet.12.default.params.PoliciesExt.num=1 -policyset.wrpacSet.12.default.params.PoliciesExt.certPolicy0.enable=true -policyset.wrpacSet.12.default.params.PoliciesExt.certPolicy0.policyId={{ wrpac_policy_oid_legal }} diff --git a/deployment/group_vars/all.yml b/deployment/group_vars/all.yml deleted file mode 100644 index 702206cd..00000000 --- a/deployment/group_vars/all.yml +++ /dev/null @@ -1,53 +0,0 @@ ---- -ca_fqdn: mw-vm-ca01.lab - -ds_instance_name: pki -ds_suffix: dc=ca,dc=lab -ds_base_dn_ca: ou=ca,dc=ca,dc=lab - -pki_instance_name: pki-tomcat - -# Test-only passwords. CS-06 §1 marks this spec as interoperability-testing only, -# not production. Override via ansible-vault for any non-test environment. -ds_dm_password: Secret.123 -pki_admin_password: Secret.123 -pki_ds_password: Secret.123 -pki_security_domain_password: Secret.123 -pki_admin_uid: caadmin -pki_admin_email: "caadmin@{{ ca_fqdn }}" - -acme_realm_type: in-memory - -# Dogtag ACME database backend. "in-memory" wipes state on pki-tomcatd restart -# (fine for throwaway smoke tests); "ds" persists into the 389-DS instance. -acme_database_backend: ds - -# 389-DS subtree where the ACME data lives when acme_database_backend == ds. -acme_db_basedn: "ou=acme,{{ ds_suffix }}" - -# WRPAC profile (CS-06 §7.4). -wrpac_profile_id: wrpacCert -wrpac_validity_days: 365 - -# WRPAC policy OIDs from ETSI TS 119 411-8 v1.1.1. -# Values below are PLACEHOLDERS in the ETSI test arc — replace with the -# OIDs assigned by the published TS 119 411-8 document for production use. -# CS-06 §7.4 #2 references NCP-l-eudiwrp (legal person) and NCP-n-eudiwrp -# (natural person). -wrpac_policy_oid_legal: "0.4.0.194112.1.99.1" -wrpac_policy_oid_natural: "0.4.0.194112.1.99.2" - -# Test WRP identity used by the direct-enrolment smoke test. -wrpac_test_organisation_name: "ACME Test Organisation B.V." -wrpac_test_organisation_identifier: "VATNL-000000000B01" -wrpac_test_country: "NL" -wrpac_test_friendly_name: "ACME RP Smoke Test" -wrpac_test_san_dns: "rp.test.lab" - -# CS-06 wrp-id identifier value used in Phase 3 (matches a seeded RP List entry). -wrpac_test_wrp_id: "NLKVK.12345678" - -# Phase 3 façade / RP List service config. -wrpac_facade_root: /opt/wrpac-acme -wrpac_facade_port: 9080 -wrpac_rp_list_port: 9000 diff --git a/deployment/inventory.yml b/deployment/inventory.yml deleted file mode 100644 index afdd7d37..00000000 --- a/deployment/inventory.yml +++ /dev/null @@ -1,6 +0,0 @@ -all: - hosts: - mw-vm-ca01.lab: - ansible_user: cbadmin - ansible_become: true - ansible_become_method: sudo diff --git a/deployment/nginx/ca-public.conf b/deployment/nginx/ca-public.conf deleted file mode 100644 index c45fabfc..00000000 --- a/deployment/nginx/ca-public.conf +++ /dev/null @@ -1,81 +0,0 @@ -# WE BUILD CS-06 CA — public-facing HTTP reverse proxy. -# -# Drop this on your nginx host (e.g. /etc/nginx/sites-available/ca-public.conf), -# replace `ca.example.com` with your real hostname, symlink into sites-enabled, -# `nginx -t && systemctl reload nginx`, then run certbot to upgrade to HTTPS. -# -# After certbot has flipped this to listen on 443, do the post-cert steps -# documented in deployment/README.md ("Exposing the CA externally") so that -# the ACME directory documents advertise your public https:// URLs instead -# of mw-vm-ca01.lab:8443/9080. - -server { - listen 80; - listen [::]:80; - server_name ca.example.com; # <-- replace - - # ---------- Stock Dogtag ACME (RFC 8555) ---------- - # Backend is HTTPS with Dogtag's self-signed cert, so we disable - # upstream verification. If you'd rather verify, drop the Dogtag root - # cert on the nginx host and switch this to: - # proxy_ssl_verify on; - # proxy_ssl_trusted_certificate /etc/ssl/dogtag-ca-root.pem; - location /acme/ { - proxy_pass https://mw-vm-ca01.lab:8443; - proxy_ssl_verify off; - proxy_ssl_server_name on; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $host; - } - - # ---------- CS-06 ACME façade ---------- - location /acme-eudi-wrpac/ { - proxy_pass http://mw-vm-ca01.lab:9080; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $host; - } - - # /tos is linked from the façade's ACME directory metadata. - location = /tos { - proxy_pass http://mw-vm-ca01.lab:9080; - proxy_set_header Host $host; - } - - # ---------- Mock Registrar / RP List ---------- - # The PUT /rp-list/{wrp_id}/acme-challenge endpoint is what an ACME - # client uses to place its key-authorization, so it must be reachable. - # All RP List endpoints are currently UNAUTHENTICATED — restrict by IP - # (e.g. allow only your test-bed network) before exposing publicly. - location /rp-list { - # allow 198.51.100.0/24; # <-- your test-bed CIDR - # deny all; - proxy_pass http://mw-vm-ca01.lab:9000; - proxy_http_version 1.1; - proxy_set_header Host $host; - } - - # ---------- Optional: Dogtag EE pages + OCSP (HTTP) ---------- - # Useful if external clients ever need to browse the profile-select pages - # or hit OCSP. Comment out if not needed. - location ^~ /ca/ee/ { - proxy_pass http://mw-vm-ca01.lab:8080; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-Proto $scheme; - } - location = /ca/ocsp { - proxy_pass http://mw-vm-ca01.lab:8080; - proxy_set_header Host $host; - } - - # ---------- Hard block credentialled surfaces ---------- - # These accept admin / agent credentials and MUST NOT be exposed publicly. - location ~ ^/ca/(admin|services|rest|agent)(/|$) { - return 403; - } -} diff --git a/deployment/playbooks/00-prereqs.yml b/deployment/playbooks/00-prereqs.yml deleted file mode 100644 index 77e5827d..00000000 --- a/deployment/playbooks/00-prereqs.yml +++ /dev/null @@ -1,74 +0,0 @@ ---- -- name: Prereqs for Dogtag PKI CA on AlmaLinux 9 - hosts: all - gather_facts: true - tasks: - - - name: Verify expected OS family - ansible.builtin.assert: - that: - - ansible_facts['os_family'] == 'RedHat' - - ansible_facts['distribution_major_version'] | int == 9 - fail_msg: >- - This playbook targets RHEL 9 derivatives (AlmaLinux/Rocky/RHEL 9.x). - Found {{ ansible_facts['distribution'] }} {{ ansible_facts['distribution_version'] }}. - - - name: Set system hostname to {{ ca_fqdn }} - ansible.builtin.hostname: - name: "{{ ca_fqdn }}" - use: systemd - - - name: Pin {{ ca_fqdn }} in /etc/hosts - ansible.builtin.lineinfile: - path: /etc/hosts - regexp: "^[0-9.]+\\s+{{ ca_fqdn | regex_escape }}(\\s|$)" - line: "{{ ansible_facts['default_ipv4']['address'] }} {{ ca_fqdn }} {{ ca_fqdn.split('.')[0] }}" - state: present - - - name: Install EPEL - ansible.builtin.dnf: - name: epel-release - state: present - - - name: Check whether CRB repo is enabled - ansible.builtin.command: dnf -q repolist --enabled - register: dnf_enabled_repos - changed_when: false - - - name: Enable CRB repo (CodeReady Builder) — required for some PKI deps - ansible.builtin.command: dnf config-manager --set-enabled crb - when: "'crb' not in dnf_enabled_repos.stdout" - - - name: Install baseline packages (firewalld, chrony, tools) - ansible.builtin.dnf: - name: - - firewalld - - chrony - - policycoreutils-python-utils - - bind-utils - - tar - - which - state: present - - - name: Enable and start chronyd - ansible.builtin.systemd: - name: chronyd - enabled: true - state: started - - - name: Enable and start firewalld - ansible.builtin.systemd: - name: firewalld - enabled: true - state: started - - - name: Open Dogtag Tomcat ports in firewalld - ansible.posix.firewalld: - port: "{{ item }}" - permanent: true - immediate: true - state: enabled - loop: - - 8080/tcp # PKI HTTP (admin redirects to 8443) - - 8443/tcp # PKI HTTPS (CA + ACME endpoint) - - 389/tcp # 389-DS LDAP (localhost-bound by default; opened for ops) diff --git a/deployment/playbooks/10-dogtag-ca.yml b/deployment/playbooks/10-dogtag-ca.yml deleted file mode 100644 index 32c119e0..00000000 --- a/deployment/playbooks/10-dogtag-ca.yml +++ /dev/null @@ -1,108 +0,0 @@ ---- -- name: Provision 389-DS instance and Dogtag CA subsystem - hosts: all - gather_facts: true - vars: - ds_inf_path: /root/ds-instance.inf - ca_cfg_path: /root/ca.cfg - ds_instance_marker: "/etc/dirsrv/slapd-{{ ds_instance_name }}/dse.ldif" - ca_install_marker: "/var/lib/pki/{{ pki_instance_name }}/conf/ca/CS.cfg" - - tasks: - - - name: Install 389-DS base package - ansible.builtin.dnf: - name: 389-ds-base - state: present - - - name: Render 389-DS instance config (inf file) - ansible.builtin.copy: - dest: "{{ ds_inf_path }}" - owner: root - group: root - mode: "0600" - content: | - [general] - config_version = 2 - - [slapd] - instance_name = {{ ds_instance_name }} - root_password = {{ ds_dm_password }} - - [backend-userroot] - sample_entries = no - suffix = {{ ds_suffix }} - - - name: Check whether the 389-DS instance already exists - ansible.builtin.stat: - path: "{{ ds_instance_marker }}" - register: ds_marker - - - name: Create 389-DS instance via dscreate - ansible.builtin.command: "dscreate from-file {{ ds_inf_path }}" - when: not ds_marker.stat.exists - register: dscreate_result - changed_when: dscreate_result.rc == 0 - - - name: Ensure 389-DS instance is running - ansible.builtin.systemd: - name: "dirsrv@{{ ds_instance_name }}" - enabled: true - state: started - - - name: Install Dogtag CA packages - ansible.builtin.dnf: - name: - - pki-ca - - pki-server - - pki-tools - state: present - - - name: Render pkispawn CA config - ansible.builtin.copy: - dest: "{{ ca_cfg_path }}" - owner: root - group: root - mode: "0600" - content: | - [DEFAULT] - pki_admin_password={{ pki_admin_password }} - pki_client_database_password={{ pki_admin_password }} - pki_client_pkcs12_password={{ pki_admin_password }} - pki_server_database_password={{ pki_admin_password }} - pki_security_domain_password={{ pki_security_domain_password }} - pki_hostname={{ ca_fqdn }} - pki_ds_url=ldap://{{ ca_fqdn }}:389 - pki_ds_bind_dn=cn=Directory Manager - pki_ds_password={{ pki_ds_password }} - pki_ds_database=ca - pki_ds_base_dn={{ ds_base_dn_ca }} - pki_admin_email={{ pki_admin_email }} - pki_admin_uid={{ pki_admin_uid }} - pki_admin_name={{ pki_admin_uid }} - pki_admin_nickname={{ pki_admin_uid }} - - [CA] - - - name: Check whether the CA subsystem is already installed - ansible.builtin.stat: - path: "{{ ca_install_marker }}" - register: ca_marker - - - name: Run pkispawn for CA subsystem - ansible.builtin.command: "pkispawn -f {{ ca_cfg_path }} -s CA" - when: not ca_marker.stat.exists - register: pkispawn_ca - changed_when: pkispawn_ca.rc == 0 - - - name: Ensure pki-tomcatd is enabled and running - ansible.builtin.systemd: - name: "pki-tomcatd@{{ pki_instance_name }}" - enabled: true - state: started - - - name: Wait for CA endpoint on 8443 - ansible.builtin.wait_for: - host: 127.0.0.1 - port: 8443 - timeout: 60 diff --git a/deployment/playbooks/20-dogtag-acme.yml b/deployment/playbooks/20-dogtag-acme.yml deleted file mode 100644 index 50a7b50f..00000000 --- a/deployment/playbooks/20-dogtag-acme.yml +++ /dev/null @@ -1,125 +0,0 @@ ---- -- name: Deploy Dogtag ACME subsystem - hosts: all - gather_facts: false - vars: - acme_conf_dir: "/var/lib/pki/{{ pki_instance_name }}/conf/acme" - acme_deploy_marker: "/var/lib/pki/{{ pki_instance_name }}/conf/Catalina/localhost/acme.xml" - - tasks: - - - name: Install pki-acme package - ansible.builtin.dnf: - name: pki-acme - state: present - - - name: Check whether acme subsystem dir exists - ansible.builtin.stat: - path: "{{ acme_conf_dir }}" - register: acme_conf_dir_stat - - - name: Create ACME subsystem (pki-server acme-create) - ansible.builtin.command: "pki-server acme-create" - when: not acme_conf_dir_stat.stat.exists - changed_when: true - - - name: Configure ACME database (in-memory) - ansible.builtin.copy: - dest: "{{ acme_conf_dir }}/database.conf" - owner: pkiuser - group: pkiuser - mode: "0660" - content: | - class=org.dogtagpki.acme.database.InMemoryDatabase - notify: Restart pki-tomcatd - - - name: Configure ACME issuer (PKIIssuer → local Dogtag CA) - ansible.builtin.copy: - dest: "{{ acme_conf_dir }}/issuer.conf" - owner: pkiuser - group: pkiuser - mode: "0660" - content: | - class=org.dogtagpki.acme.issuer.PKIIssuer - url=https://{{ ca_fqdn }}:8443 - profile=acmeServerCert - username={{ pki_admin_uid }} - password={{ pki_admin_password }} - notify: Restart pki-tomcatd - - - name: Configure ACME realm (in-memory) - ansible.builtin.copy: - dest: "{{ acme_conf_dir }}/realm.conf" - owner: pkiuser - group: pkiuser - mode: "0660" - content: | - class=org.dogtagpki.acme.realm.InMemoryRealm - username=admin - password={{ pki_admin_password }} - notify: Restart pki-tomcatd - - - name: Configure ACME engine (enable, retention, etc.) - ansible.builtin.copy: - dest: "{{ acme_conf_dir }}/engine.conf" - owner: pkiuser - group: pkiuser - mode: "0660" - content: | - enabled=true - policy.wildcard=false - policy.retention.nonces.length=30 - policy.retention.nonces.unit=MINUTES - policy.retention.pendingAuthorizations.length=30 - policy.retention.pendingAuthorizations.unit=MINUTES - policy.retention.invalidAuthorizations.length=30 - policy.retention.invalidAuthorizations.unit=MINUTES - policy.retention.validAuthorizations.length=30 - policy.retention.validAuthorizations.unit=MINUTES - policy.retention.pendingOrders.length=30 - policy.retention.pendingOrders.unit=MINUTES - policy.retention.invalidOrders.length=30 - policy.retention.invalidOrders.unit=MINUTES - policy.retention.readyOrders.length=30 - policy.retention.readyOrders.unit=MINUTES - policy.retention.processingOrders.length=30 - policy.retention.processingOrders.unit=MINUTES - policy.retention.validOrders.length=30 - policy.retention.validOrders.unit=MINUTES - policy.retention.certificates.length=30 - policy.retention.certificates.unit=DAYS - notify: Restart pki-tomcatd - - - name: Check whether ACME webapp is already deployed - ansible.builtin.stat: - path: "{{ acme_deploy_marker }}" - register: acme_deploy_stat - - - name: Deploy ACME webapp (pki-server acme-deploy) - ansible.builtin.command: "pki-server acme-deploy" - when: not acme_deploy_stat.stat.exists - changed_when: true - notify: Restart pki-tomcatd - - - name: Flush handlers (restart pki-tomcatd if needed) - ansible.builtin.meta: flush_handlers - - - name: Wait for ACME directory endpoint - ansible.builtin.uri: - url: "https://{{ ca_fqdn }}:8443/acme/directory" - validate_certs: false - return_content: true - register: acme_directory - retries: 12 - delay: 5 - until: acme_directory.status == 200 - - - name: Show ACME directory response - ansible.builtin.debug: - var: acme_directory.json - - handlers: - - name: Restart pki-tomcatd - ansible.builtin.systemd: - name: "pki-tomcatd@{{ pki_instance_name }}" - state: restarted diff --git a/deployment/playbooks/21-acme-persistence.yml b/deployment/playbooks/21-acme-persistence.yml deleted file mode 100644 index 53c2f792..00000000 --- a/deployment/playbooks/21-acme-persistence.yml +++ /dev/null @@ -1,177 +0,0 @@ ---- -# Switch the Dogtag ACME subsystem from the in-memory backend to the 389-DS -# backend so that accounts, orders, authz, challenges, and certificates -# survive pki-tomcatd restarts. -# -# Safe to run on a deployment that already has the in-memory backend running. -# -# What this playbook does: -# 1. Ensure DS schema for ACME attribute / object types is loaded -# 2. Create the ACME subtree (ou=acme,...) and child OUs (accounts, orders, ...) -# 3. Apply DS indexes the ACME engine queries on -# 4. Rewrite /var/lib/pki/pki-tomcat/conf/acme/database.conf to use DSDatabase -# 5. Restart pki-tomcatd and wait for /acme/directory to respond - -- name: Switch Dogtag ACME persistence to 389-DS - hosts: all - gather_facts: false - vars: - acme_db_url: "ldap://{{ ca_fqdn }}:389" - acme_db_bind_dn: "cn=Directory Manager" - acme_db_bind_password: "{{ ds_dm_password }}" - schema_src: /usr/share/pki/acme/database/ds/schema.ldif - index_src: /usr/share/pki/acme/database/ds/index.ldif - indextask_src: /usr/share/pki/acme/database/ds/indextask.ldif - - tasks: - - - name: Sanity-check we want the ds backend - ansible.builtin.assert: - that: acme_database_backend == "ds" - fail_msg: >- - acme_database_backend is {{ acme_database_backend }}. - Set it to "ds" in group_vars/all.yml before running this playbook. - - - name: Load ACME schema into 389-DS (continue-on-error; tolerate "already exists") - ansible.builtin.command: - argv: - - ldapmodify - - -c - - -H - - "{{ acme_db_url }}" - - -D - - "{{ acme_db_bind_dn }}" - - -w - - "{{ acme_db_bind_password }}" - - -f - - "{{ schema_src }}" - register: schema_apply - changed_when: "'modifying entry' in schema_apply.stdout" - failed_when: >- - schema_apply.rc != 0 - and 'Already exists' not in (schema_apply.stderr | default('')) - and 'Type or value exists' not in (schema_apply.stderr | default('')) - - - name: Write per-deployment create.ldif (root suffix + ACME subtree + child OUs) - ansible.builtin.copy: - dest: /root/acme-create.ldif - owner: root - group: root - mode: "0600" - content: | - dn: {{ ds_suffix }} - objectClass: top - objectClass: domain - dc: {{ ds_suffix.split(',')[0].split('=')[1] }} - - dn: {{ acme_db_basedn }} - objectClass: organizationalUnit - objectClass: extensibleObject - ou: acme - - dn: ou=config,{{ acme_db_basedn }} - objectClass: organizationalUnit - objectClass: extensibleObject - ou: config - - dn: ou=nonces,{{ acme_db_basedn }} - objectClass: organizationalUnit - ou: nonces - - dn: ou=accounts,{{ acme_db_basedn }} - objectClass: organizationalUnit - ou: accounts - - dn: ou=orders,{{ acme_db_basedn }} - objectClass: organizationalUnit - ou: orders - - dn: ou=authorizations,{{ acme_db_basedn }} - objectClass: organizationalUnit - ou: authorizations - - dn: ou=challenges,{{ acme_db_basedn }} - objectClass: organizationalUnit - ou: challenges - - dn: ou=certificates,{{ acme_db_basedn }} - objectClass: organizationalUnit - ou: certificates - - - name: Create root suffix + ACME subtree (tolerate already-exists) - ansible.builtin.command: - argv: - - ldapadd - - -c - - -H - - "{{ acme_db_url }}" - - -D - - "{{ acme_db_bind_dn }}" - - -w - - "{{ acme_db_bind_password }}" - - -f - - /root/acme-create.ldif - register: subtree_create - changed_when: "'adding new entry' in subtree_create.stdout" - # ldapadd -c returns rc=68 if every entry already exists, rc=0 if all new. - # Tolerate "Already exists" lines but fail on anything else. - failed_when: >- - subtree_create.rc != 0 - and ('No such object' in (subtree_create.stderr | default('')) - or 'Invalid' in (subtree_create.stderr | default('')) - or ('Already exists' not in (subtree_create.stderr | default('')) - and 'Already exists' not in (subtree_create.stdout | default('')))) - - - name: Apply ACME indexes - ansible.builtin.command: > - ldapadd -c -H {{ acme_db_url }} - -D "{{ acme_db_bind_dn }}" -w "{{ acme_db_bind_password }}" - -f {{ index_src }} - register: index_apply - failed_when: index_apply.rc != 0 and 'Already exists' not in (index_apply.stderr | default('') + index_apply.stdout | default('')) - changed_when: "'adding new entry' in (index_apply.stdout | default(''))" - - - name: Trigger ACME index task (one-shot reindex) - ansible.builtin.command: > - ldapadd -c -H {{ acme_db_url }} - -D "{{ acme_db_bind_dn }}" -w "{{ acme_db_bind_password }}" - -f {{ indextask_src }} - register: indextask_apply - failed_when: indextask_apply.rc != 0 and 'Already exists' not in (indextask_apply.stderr | default('') + indextask_apply.stdout | default('')) - changed_when: "'adding new entry' in (indextask_apply.stdout | default(''))" - - - name: Rewrite ACME database.conf to use DSDatabase - ansible.builtin.copy: - dest: "/var/lib/pki/{{ pki_instance_name }}/conf/acme/database.conf" - owner: pkiuser - group: pkiuser - mode: "0660" - content: | - class=org.dogtagpki.acme.database.DSDatabase - url={{ acme_db_url }} - authType=BasicAuth - bindDN={{ acme_db_bind_dn }} - bindPassword={{ acme_db_bind_password }} - baseDN={{ acme_db_basedn }} - register: db_conf - - - name: Restart pki-tomcatd - ansible.builtin.systemd: - name: "pki-tomcatd@{{ pki_instance_name }}" - state: restarted - when: db_conf is changed - - - name: Wait for ACME directory to respond - ansible.builtin.uri: - url: "https://{{ ca_fqdn }}:8443/acme/directory" - validate_certs: false - register: acme_dir - retries: 12 - delay: 5 - until: acme_dir.status == 200 - - - name: Phase 4 done — ACME persistence on 389-DS - ansible.builtin.debug: - msg: - - "ACME baseDN: {{ acme_db_basedn }}" - - "directory: https://{{ ca_fqdn }}:8443/acme/directory" diff --git a/deployment/playbooks/30-wrpac-profile.yml b/deployment/playbooks/30-wrpac-profile.yml deleted file mode 100644 index 739b9559..00000000 --- a/deployment/playbooks/30-wrpac-profile.yml +++ /dev/null @@ -1,75 +0,0 @@ ---- -- name: Deploy WRPAC certificate profile (CS-06 §7.4) - hosts: all - gather_facts: false - vars: - ca_cs_cfg: "/var/lib/pki/{{ pki_instance_name }}/conf/ca/CS.cfg" - profile_dir: "/var/lib/pki/{{ pki_instance_name }}/conf/ca/profiles/ca" - profile_file: "{{ profile_dir }}/{{ wrpac_profile_id }}.cfg" - - tasks: - - - name: Render WRPAC profile in place - ansible.builtin.template: - src: "../files/{{ wrpac_profile_id }}.cfg.j2" - dest: "{{ profile_file }}" - owner: pkiuser - group: pkiuser - mode: "0660" - register: profile_rendered - - - name: Register WRPAC profile class_id in CS.cfg - ansible.builtin.lineinfile: - path: "{{ ca_cs_cfg }}" - regexp: "^profile\\.{{ wrpac_profile_id }}\\.class_id=" - line: "profile.{{ wrpac_profile_id }}.class_id=caEnrollImpl" - owner: pkiuser - group: pkiuser - mode: "0660" - register: classid_registered - - - name: Read current profile.list - ansible.builtin.command: "grep -E '^profile.list=' {{ ca_cs_cfg }}" - register: profile_list_line - changed_when: false - - - name: Append WRPAC to profile.list if missing - ansible.builtin.lineinfile: - path: "{{ ca_cs_cfg }}" - regexp: "^profile\\.list=" - line: "{{ profile_list_line.stdout }},{{ wrpac_profile_id }}" - owner: pkiuser - group: pkiuser - mode: "0660" - when: wrpac_profile_id not in profile_list_line.stdout - register: list_registered - - - name: Restart pki-tomcatd to load profile - ansible.builtin.systemd: - name: "pki-tomcatd@{{ pki_instance_name }}" - state: restarted - when: profile_rendered is changed or classid_registered is changed or (list_registered is defined and list_registered is changed) - - - name: Wait for CA endpoint after restart - ansible.builtin.wait_for: - host: 127.0.0.1 - port: 8443 - timeout: 60 - - - name: Verify WRPAC profile is loaded via EE profileSelect - ansible.builtin.uri: - url: "https://{{ ca_fqdn }}:8443/ca/ee/ca/profileSelect?profileId={{ wrpac_profile_id }}" - validate_certs: false - return_content: true - register: profile_select - retries: 6 - delay: 5 - until: "profile_select.status == 200 and 'inputPluginListSet' in profile_select.content" - - - name: Assert profileSelect returned an EE form for the profile - ansible.builtin.assert: - that: - - profile_select.status == 200 - - "'wrpacCert' in profile_select.url or wrpac_profile_id in profile_select.url" - - "'Error' not in profile_select.content[:2000]" - fail_msg: "WRPAC profile EE form did not render — profile may not be loaded" diff --git a/deployment/playbooks/31-wrpac-smoke-direct.yml b/deployment/playbooks/31-wrpac-smoke-direct.yml deleted file mode 100644 index 9a6f5efd..00000000 --- a/deployment/playbooks/31-wrpac-smoke-direct.yml +++ /dev/null @@ -1,194 +0,0 @@ ---- -- name: WRPAC direct-enrolment smoke test (CS-06 §7.4) - hosts: all - gather_facts: false - vars: - smoke_dir: /root/wrpac-smoke - nss_dir: "{{ smoke_dir }}/nssdb" - nss_password: Secret.123 - csr_conf: "{{ smoke_dir }}/csr.conf" - csr_file: "{{ smoke_dir }}/wrpac.csr" - key_file: "{{ smoke_dir }}/wrpac.key" - cert_file: "{{ smoke_dir }}/wrpac.pem" - admin_p12: "/root/.dogtag/{{ pki_instance_name }}/ca_admin_cert.p12" - - tasks: - - - name: Ensure smoke test working directory - ansible.builtin.file: - path: "{{ smoke_dir }}" - state: directory - owner: root - group: root - mode: "0700" - - - name: Write openssl CSR config for WRPAC Subject DN - ansible.builtin.copy: - dest: "{{ csr_conf }}" - owner: root - group: root - mode: "0600" - content: | - [ req ] - distinguished_name = dn - prompt = no - req_extensions = v3_req - - [ dn ] - CN = {{ wrpac_test_friendly_name }} - O = {{ wrpac_test_organisation_name }} - organizationIdentifier = {{ wrpac_test_organisation_identifier }} - C = {{ wrpac_test_country }} - - [ v3_req ] - subjectAltName = @san - - [ san ] - DNS.1 = {{ wrpac_test_san_dns }} - - - name: Generate RSA-3072 key and CSR - ansible.builtin.command: > - openssl req -new - -newkey rsa:3072 - -nodes - -keyout {{ key_file }} - -out {{ csr_file }} - -config {{ csr_conf }} - args: - creates: "{{ csr_file }}" - - - name: Show the generated CSR subject - ansible.builtin.command: "openssl req -in {{ csr_file }} -noout -subject -nameopt RFC2253" - register: csr_subject - changed_when: false - - - name: Assert CSR Subject contains organizationIdentifier - ansible.builtin.assert: - that: - - "('organizationIdentifier=' + wrpac_test_organisation_identifier) in csr_subject.stdout or ('2.5.4.97=' + wrpac_test_organisation_identifier) in csr_subject.stdout" - fail_msg: "Generated CSR lacks organizationIdentifier in Subject: {{ csr_subject.stdout }}" - - - name: Initialize pki client NSS DB - ansible.builtin.command: "pki -d {{ nss_dir }} -c {{ nss_password }} client-init --force" - args: - creates: "{{ nss_dir }}/cert9.db" - - - name: Import caadmin client cert into NSS DB - ansible.builtin.shell: | - set -e - if pki -d {{ nss_dir }} -c {{ nss_password }} nss-cert-find 2>/dev/null | grep -q "Nickname:"; then - echo "already imported" - exit 0 - fi - pki -d {{ nss_dir }} -c {{ nss_password }} \ - pkcs12-import \ - --pkcs12 {{ admin_p12 }} \ - --pkcs12-password '{{ pki_admin_password }}' - register: import_admin - changed_when: "'already imported' not in import_admin.stdout" - - - name: Discover admin cert nickname - ansible.builtin.shell: | - set -e -o pipefail - pki -d {{ nss_dir }} -c {{ nss_password }} nss-cert-find \ - | awk -F': ' '/Nickname:/ {print $2; exit}' - args: - executable: /bin/bash - register: admin_nickname_raw - changed_when: false - - - name: Set admin_nickname fact - ansible.builtin.set_fact: - admin_nickname: "{{ admin_nickname_raw.stdout | trim }}" - - - name: Export CA signing cert from Dogtag - ansible.builtin.command: > - pki-server cert-export ca_signing - --cert-file {{ smoke_dir }}/ca_signing.crt - args: - creates: "{{ smoke_dir }}/ca_signing.crt" - - - name: Import CA signing cert into NSS DB as trusted - ansible.builtin.shell: | - set -e -o pipefail - if pki -d {{ nss_dir }} -c {{ nss_password }} nss-cert-find \ - | grep -E "^\s+Nickname:\s+lab-ca-signing\s*$" >/dev/null; then - echo "already imported" - exit 0 - fi - pki -d {{ nss_dir }} -c {{ nss_password }} \ - nss-cert-import \ - --cert {{ smoke_dir }}/ca_signing.crt \ - --trust CT,C,C \ - "lab-ca-signing" - args: - executable: /bin/bash - register: ca_import - changed_when: "'already imported' not in ca_import.stdout" - - - name: Submit CSR for enrolment with profile {{ wrpac_profile_id }} - ansible.builtin.command: > - pki -d {{ nss_dir }} -c {{ nss_password }} - -n "{{ admin_nickname }}" - -U https://{{ ca_fqdn }}:8443 - ca-cert-request-submit - --profile {{ wrpac_profile_id }} - --csr-file {{ csr_file }} - register: request_submit - - - name: Show submission output - ansible.builtin.debug: - msg: "{{ request_submit.stdout_lines }}" - - - name: Assert request was auto-issued (agent submitter ⇒ complete on submit) - ansible.builtin.assert: - that: - - "'Request Status: complete' in request_submit.stdout" - - "'Operation Result: success' in request_submit.stdout" - - "'Certificate ID:' in request_submit.stdout" - fail_msg: "Request was not auto-issued — see submit output" - - - name: Capture Certificate ID - ansible.builtin.set_fact: - cert_id: "{{ request_submit.stdout | regex_search('Certificate ID:\\s*(\\S+)', '\\1') | first }}" - - - name: Remove any stale cert file - ansible.builtin.file: - path: "{{ cert_file }}" - state: absent - - - name: Export issued certificate to PEM - ansible.builtin.shell: | - pki -d {{ nss_dir }} -c {{ nss_password }} \ - -n "{{ admin_nickname }}" \ - -U https://{{ ca_fqdn }}:8443 \ - ca-cert-show {{ cert_id }} \ - --output {{ cert_file }} - args: - executable: /bin/bash - - - name: Dump issued cert (text) - ansible.builtin.command: "openssl x509 -in {{ cert_file }} -noout -text -nameopt RFC2253" - register: cert_text - changed_when: false - - - name: Show issued certificate - ansible.builtin.debug: - msg: "{{ cert_text.stdout_lines }}" - - - name: Verify CS-06 §7.4 attributes - ansible.builtin.assert: - that: - # #5 organizationIdentifier (OID 2.5.4.97) - - "'organizationIdentifier=' + wrpac_test_organisation_identifier in cert_text.stdout or '2.5.4.97=' + wrpac_test_organisation_identifier in cert_text.stdout" - # #3 legal name in O= - - "'O=' + wrpac_test_organisation_name in cert_text.stdout" - # #6 certificate policy OID - - wrpac_policy_oid_legal in cert_text.stdout - # #8 SAN extension with DNS - - "'DNS:' + wrpac_test_san_dns in cert_text.stdout" - # #13 1-year validity — check the cert is not 90-day - - "'Not After' in cert_text.stdout" - # #12 SHA-256+ signature - - "'sha256' in cert_text.stdout or 'sha384' in cert_text.stdout or 'sha512' in cert_text.stdout" - fail_msg: "Issued cert does not meet CS-06 §7.4 attribute checklist" diff --git a/deployment/playbooks/40-wrpac-facade.yml b/deployment/playbooks/40-wrpac-facade.yml deleted file mode 100644 index 97991ed9..00000000 --- a/deployment/playbooks/40-wrpac-facade.yml +++ /dev/null @@ -1,184 +0,0 @@ ---- -- name: Deploy WRPAC ACME façade + mock RP List service (CS-06 Phase 3) - hosts: all - gather_facts: false - vars: - src_root: "{{ playbook_dir }}/../files/wrpac-acme" - - tasks: - - - name: Ensure firewalld ports are open (façade + RP List) - ansible.posix.firewalld: - port: "{{ item }}" - permanent: true - immediate: true - state: enabled - loop: - - "{{ wrpac_facade_port }}/tcp" - - "{{ wrpac_rp_list_port }}/tcp" - - - name: Ensure target install directory - ansible.builtin.file: - path: "{{ wrpac_facade_root }}" - state: directory - owner: root - group: root - mode: "0755" - - - name: Ensure persistent state directory - ansible.builtin.file: - path: /var/lib/wrpac-acme - state: directory - owner: root - group: root - mode: "0700" - - - name: Remove any stale WRPAC_BASE_URL override (now derived from request) - ansible.builtin.file: - path: /etc/systemd/system/wrpac-acme-facade.service.d - state: absent - register: stale_override - - - name: Sync wrpac-acme source tree - ansible.builtin.copy: - src: "{{ src_root }}/" - dest: "{{ wrpac_facade_root }}/" - owner: root - group: root - mode: preserve - directory_mode: "0755" - register: src_sync - - - name: Create venv - ansible.builtin.command: "python3 -m venv {{ wrpac_facade_root }}/.venv" - args: - creates: "{{ wrpac_facade_root }}/.venv/bin/python" - - - name: Install Python dependencies into venv - ansible.builtin.pip: - name: - - "fastapi>=0.110,<0.116" - - "uvicorn[standard]>=0.27,<0.32" - - "pydantic>=2.5,<3" - - "cryptography>=42,<44" - - "jwcrypto>=1.5,<2" - - "httpx>=0.26,<0.28" - virtualenv: "{{ wrpac_facade_root }}/.venv" - state: present - - - name: Install wrpac-acme package itself (editable) - ansible.builtin.command: - cmd: "{{ wrpac_facade_root }}/.venv/bin/pip install -e ." - chdir: "{{ wrpac_facade_root }}" - register: pip_install_self - changed_when: "'Successfully installed' in pip_install_self.stdout" - - - name: Write systemd unit — RP List service - ansible.builtin.copy: - dest: /etc/systemd/system/wrpac-rp-list.service - owner: root - group: root - mode: "0644" - content: | - [Unit] - Description=WE BUILD mock RP List service - After=network.target - - [Service] - Type=simple - User=root - Environment="WRPAC_SEED_WRP_ID={{ wrpac_test_wrp_id }}" - Environment="WRPAC_SEED_LEGAL_NAME={{ wrpac_test_organisation_name }}" - Environment="WRPAC_SEED_ORG_ID={{ wrpac_test_organisation_identifier }}" - Environment="WRPAC_SEED_COUNTRY={{ wrpac_test_country }}" - Environment="WRPAC_RP_LIST_DB_PATH=/var/lib/wrpac-acme/rp_list.sqlite3" - ExecStart={{ wrpac_facade_root }}/.venv/bin/uvicorn wrpac_acme.rp_list:app \ - --host 0.0.0.0 --port {{ wrpac_rp_list_port }} - Restart=on-failure - - [Install] - WantedBy=multi-user.target - register: rp_list_unit - notify: Reload systemd - - - name: Write systemd unit — ACME façade - ansible.builtin.copy: - dest: /etc/systemd/system/wrpac-acme-facade.service - owner: root - group: root - mode: "0644" - content: | - [Unit] - Description=WE BUILD CS-06 ACME façade - After=network.target wrpac-rp-list.service - Wants=wrpac-rp-list.service - - [Service] - Type=simple - User=root - Environment="WRPAC_BASE_URL=http://{{ ca_fqdn }}:{{ wrpac_facade_port }}" - Environment="WRPAC_RP_LIST_URL=http://localhost:{{ wrpac_rp_list_port }}" - Environment="WRPAC_NSS_DIR=/root/wrpac-smoke/nssdb" - Environment="WRPAC_NSS_PASSWORD={{ pki_admin_password }}" - Environment="WRPAC_ADMIN_NICKNAME=PKI Administrator for lab" - Environment="WRPAC_CA_URL=https://{{ ca_fqdn }}:8443" - Environment="WRPAC_PROFILE_ID={{ wrpac_profile_id }}" - Environment="WRPAC_DB_PATH=/var/lib/wrpac-acme/facade.sqlite3" - ExecStart={{ wrpac_facade_root }}/.venv/bin/uvicorn wrpac_acme.facade:app \ - --host 0.0.0.0 --port {{ wrpac_facade_port }} \ - --proxy-headers --forwarded-allow-ips '*' - Restart=on-failure - - [Install] - WantedBy=multi-user.target - register: facade_unit - notify: Reload systemd - - - name: Flush handlers - ansible.builtin.meta: flush_handlers - - - name: Ensure RP List service running + restart if code or unit changed - ansible.builtin.systemd: - name: wrpac-rp-list - enabled: true - state: "{{ 'restarted' if (src_sync is changed or rp_list_unit is changed) else 'started' }}" - - - name: Ensure ACME façade running + restart if code or unit changed - ansible.builtin.systemd: - name: wrpac-acme-facade - enabled: true - state: "{{ 'restarted' if (src_sync is changed or facade_unit is changed) else 'started' }}" - - - name: Wait for RP List port - ansible.builtin.wait_for: - host: 127.0.0.1 - port: "{{ wrpac_rp_list_port }}" - timeout: 30 - - - name: Wait for façade port - ansible.builtin.wait_for: - host: 127.0.0.1 - port: "{{ wrpac_facade_port }}" - timeout: 30 - - - name: Sanity-check façade directory - ansible.builtin.uri: - url: "http://{{ ca_fqdn }}:{{ wrpac_facade_port }}/acme-eudi-wrpac/directory" - return_content: true - register: facade_directory - - - name: Assert directory advertises CS-06 extensions - ansible.builtin.assert: - that: - - "'wrp-id' in facade_directory.json.meta.supportedIdentifierTypes" - - "'registrar-api-01' in facade_directory.json.meta.supportedChallengeTypes" - - facade_directory.json.meta.externalAccountRequired - - # End-to-end smoke is covered by tests/test_04 (which runs from the - # control machine so it isn't affected by hairpin NAT when the directory - # advertises public URLs). The deploy playbook only verifies the directory. - - handlers: - - name: Reload systemd - ansible.builtin.systemd: - daemon_reload: true diff --git a/deployment/playbooks/99-smoke-test.yml b/deployment/playbooks/99-smoke-test.yml deleted file mode 100644 index dbc8a147..00000000 --- a/deployment/playbooks/99-smoke-test.yml +++ /dev/null @@ -1,106 +0,0 @@ ---- -- name: ACME end-to-end smoke test - hosts: all - gather_facts: false - vars: - acme_dir: "https://{{ ca_fqdn }}:8443/acme/directory" - smoke_home: /root/acme-smoke - smoke_email: "smoke@{{ ca_fqdn }}" - smoke_identifier: "{{ ca_fqdn }}" - - tasks: - - - name: Open port 80 for http-01 challenge listener - ansible.posix.firewalld: - port: 80/tcp - permanent: true - immediate: true - state: enabled - - - name: Sanity-check ACME directory responds - ansible.builtin.uri: - url: "{{ acme_dir }}" - validate_certs: false - return_content: true - register: smoke_directory - - - name: Assert directory has the expected endpoints - ansible.builtin.assert: - that: - - smoke_directory.json.newNonce is defined - - smoke_directory.json.newAccount is defined - - smoke_directory.json.newOrder is defined - fail_msg: "ACME directory is missing required endpoints" - - - name: Ensure acme.sh is installed - ansible.builtin.command: "/root/.acme.sh/acme.sh --version" - register: acme_sh_check - changed_when: false - failed_when: false - - - name: Install acme.sh via curl bootstrap - when: acme_sh_check.rc != 0 - block: - - name: Install acme.sh dependencies - ansible.builtin.dnf: - name: - - curl - - socat - - openssl - state: present - - - name: Ensure acme.sh source dir - ansible.builtin.file: - path: /root/acme.sh-src - state: directory - mode: "0700" - - - name: Download acme.sh installer - ansible.builtin.get_url: - url: "https://raw.githubusercontent.com/acmesh-official/acme.sh/master/acme.sh" - dest: /root/acme.sh-src/acme.sh - mode: "0700" - validate_certs: true - - - name: Run acme.sh installer - ansible.builtin.command: "./acme.sh --install -m {{ smoke_email }}" - args: - chdir: /root/acme.sh-src - creates: /root/.acme.sh/acme.sh - - - name: Register ACME account (idempotent — acme.sh skips if already registered) - ansible.builtin.command: > - /root/.acme.sh/acme.sh - --server {{ acme_dir }} - --register-account - --insecure - -m {{ smoke_email }} - register: acme_register - changed_when: "'Already registered' not in acme_register.stdout" - - - name: Issue a certificate via standalone http-01 (RSA-2048 — see acmeServerCert profile) - ansible.builtin.command: > - /root/.acme.sh/acme.sh - --server {{ acme_dir }} - --issue - --standalone - --insecure - --keylength 2048 - -d {{ smoke_identifier }} - --force - register: acme_issue - changed_when: true - - - name: Show issued certificate path - ansible.builtin.command: "ls /root/.acme.sh/{{ smoke_identifier }}_ecc/ /root/.acme.sh/{{ smoke_identifier }}/" - register: cert_dir - changed_when: false - failed_when: false - - - name: Print smoke-test summary - ansible.builtin.debug: - msg: - - "ACME directory: {{ acme_dir }}" - - "Test identifier: {{ smoke_identifier }}" - - "Issued cert dir: {{ cert_dir.stdout_lines | default([]) }}" - - "Last acme.sh exit: {{ acme_issue.rc }}" diff --git a/deployment/requirements.txt b/deployment/requirements.txt deleted file mode 100644 index 0cb2ed59..00000000 --- a/deployment/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -ansible-core>=2.16,<2.18 diff --git a/deployment/requirements.yml b/deployment/requirements.yml deleted file mode 100644 index e079b929..00000000 --- a/deployment/requirements.yml +++ /dev/null @@ -1,6 +0,0 @@ ---- -collections: - - name: ansible.posix - version: ">=1.5.0,<3.0.0" - - name: community.general - version: ">=8.0.0,<11.0.0" diff --git a/deployment/site.yml b/deployment/site.yml deleted file mode 100644 index d2fc5a6d..00000000 --- a/deployment/site.yml +++ /dev/null @@ -1,10 +0,0 @@ ---- -- import_playbook: playbooks/00-prereqs.yml -- import_playbook: playbooks/10-dogtag-ca.yml -- import_playbook: playbooks/20-dogtag-acme.yml -- import_playbook: playbooks/21-acme-persistence.yml - when: acme_database_backend | default('in-memory') == 'ds' -- import_playbook: playbooks/30-wrpac-profile.yml -- import_playbook: playbooks/31-wrpac-smoke-direct.yml -- import_playbook: playbooks/40-wrpac-facade.yml -- import_playbook: playbooks/99-smoke-test.yml diff --git a/deployment/test-out/dogtag-ca-root.pem b/deployment/test-out/dogtag-ca-root.pem deleted file mode 100644 index bf72689a..00000000 --- a/deployment/test-out/dogtag-ca-root.pem +++ /dev/null @@ -1,27 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIEljCCAv6gAwIBAgIRAOUZGwH/qWUU8lDDpXnDfSAwDQYJKoZIhvcNAQELBQAw -VDEcMBoGA1UECgwTbGFiIFNlY3VyaXR5IERvbWFpbjETMBEGA1UECwwKcGtpLXRv -bWNhdDEfMB0GA1UEAwwWQ0EgU2lnbmluZyBDZXJ0aWZpY2F0ZTAeFw0yNjA1MTkw -OTU5MzFaFw00NjA1MTkwOTU5MzFaMFQxHDAaBgNVBAoME2xhYiBTZWN1cml0eSBE -b21haW4xEzARBgNVBAsMCnBraS10b21jYXQxHzAdBgNVBAMMFkNBIFNpZ25pbmcg -Q2VydGlmaWNhdGUwggGiMA0GCSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQDWJtWL -5K+ZhqQ5Ef/80ZU0ChfcDy5yLcf0JYEgPTBG7Rnrc2RbdqFnZEL7GmAPfGhchAzl -sWuZ6VgwB2gUfldnugH+nON+u4OumVc4pmx0zDqhooJmevZlm7/npVmmL2zPVB4R -fjAf1KOD62luFhucIGh+loGVT5Mspxx/zahjadfqjqEM5ef7KJ/57HA9NPrRU6wB -mpn6DuqZKOUWnk0Pm2Se7+0YfVoiBl0UxemvOXHt5HIToLSytLrxJSL1fy31KgvV -M56ttrGoTjA3EHINi6XYxFkxmfpFpOy4V0fpW1uIUSGmfv/bkOidQvqmXvriPMCD -BVU6p/0oszxYPe9AQbaTkEUIq3eo62ANMtLT6p15WRRV+O63A8w7LEKB7V0XLY7C -B0L1PFCmdKcDyEYAEoq1cXubdT1PaR8GGcGt2Hw9a9eGAzu+edh8KAIGBbUHW6fM -uHmMBKTbK1tKmTGMEauiSgcHqFfqrTKtAA2AF67clDOs6K8z0NXBRaog1B8CAwEA -AaNjMGEwHQYDVR0OBBYEFH9fCueXVdjrb7JHonbQ/mrys2xEMB8GA1UdIwQYMBaA -FH9fCueXVdjrb7JHonbQ/mrys2xEMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/ -BAQDAgHGMA0GCSqGSIb3DQEBCwUAA4IBgQDGOLThN0Soe+XDflnYWcWu7JPRgMrc -yxS7gg5i0dzmB2iIvuQJ/SNtg0PSuK95rktTnPEwadFDnmkhN+nNGdL50QLLwI9H -AeeSJHP/xUzz2MLykgYp6tx0U56gUh8N1B9rrPOaZ+UCa7QjGUs3/lczrBVbJwlT -+YpqJ/VUlTFIhsNast8qwHSoO9vX+Lyt8WINe5vSZ9oUAstQNDKvXGI68IIyZt0x -t1kIm+feaiFVcdy2RjbuB1qJO8JhWYTHPbnKvy7D7LZ+iLtuKS9idd+2VpGrBrcd -6WIi52Tg7ZE0p3ZsaP99+aCLn6BxDisbzy8H6vx7FFnZfZDMF1Sv6fGD38e9KirI -Mgvjb4N7Gg7lJCEpBOLZ1zDRmSqInta0Mr99+agt3MNEetmkUJkJLnTZa0hatT/v -rhjcGNYh20BLKNU5KMBxUzpGLU5abZuCUGFGBNASp8qJVjBovanR+TLL7ji2z7hS -tjD/zOxvwCAxmubPWT9xlCgbxZeCqyz+8tY= ------END CERTIFICATE----- diff --git a/deployment/test-out/phase3-1779199136.pem b/deployment/test-out/phase3-1779199136.pem deleted file mode 100644 index b0e7bb22..00000000 --- a/deployment/test-out/phase3-1779199136.pem +++ /dev/null @@ -1,26 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIFOzCCA6OgAwIBAgIQG7ogxQRLXFjSXLq7dJRj6TANBgkqhkiG9w0BAQsFADBUMRwwGgYDVQQK -DBNsYWIgU2VjdXJpdHkgRG9tYWluMRMwEQYDVQQLDApwa2ktdG9tY2F0MR8wHQYDVQQDDBZDQSBT -aWduaW5nIENlcnRpZmljYXRlMB4XDTI2MDUxOTEzNTg1OVoXDTI3MDUxOTEzNTg1OVowbTELMAkG -A1UEBhMCTkwxGzAZBgNVBGEMElZBVE5MLTAwMDAwMDAwMEIwMTEkMCIGA1UECgwbQUNNRSBUZXN0 -IE9yZ2FuaXNhdGlvbiBCLlYuMRswGQYDVQQDDBJBQ01FIFJQIFNtb2tlIFRlc3QwggGiMA0GCSqG -SIb3DQEBAQUAA4IBjwAwggGKAoIBgQDHRlGzjQzOrtRN9YSDAvmEsQRN+eJjy0sP1BdhvP5XEBMt -mUaoPvpWxboqwgSXHF+FT1W9L7CCfLBNGLPHTY5wqCDVeUsmQAr2d53HnGBAwNbGf4bKrF6obYfK -eUqBRbH9v5m/puiXJUF4q3eDqDr63LvNCdsNWrt68LQdzsEU7UYPy1y2wzAZ2aqS1J0GP3dvXOvM -sUqydos4LNxlQcTQUjUg+h6PBZnS3aiKKpUOF7gcy3vqrS1KEp6NWuCq59JNj4VCKBYTBNO6paPZ -mZfcQUFEko32NIUVnRMV4N6gwHONSrcbqDILMsCg8v34DKGHQ2x/25sfADABpMocXXSIAjf5Emp+ -nZ/k8blSzJSQfUVTkT1Z4pwaklojwQ6layv9p8grm7MVXQmq1O5kKINy256o8RtoAbxZ4i2dFsd3 -de8aEfc5GD8C+rGn7TJ/o5+ZdeaqhDHTfgXmr1xW1SaW0TpcC1r+qfx2pmzGuSYGwtO3PbWrS3WG -tQIyI+1jD7kCAwEAAaOB7zCB7DAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwIG -CCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUf18K55dV2OtvskeidtD+avKzbEQw -PgYIKwYBBQUHAQEEMjAwMC4GCCsGAQUFBzABhiJodHRwOi8vbXctdm0tY2EwMS5sYWI6ODA4MC9j -YS9vY3NwMBYGA1UdEQQPMA2CC3JwLnRlc3QubGFiMB0GA1UdDgQWBBSFjORturoGpExBG19LXMJs -RJIeezAVBgNVHSAEDjAMMAoGCAQAi+xAAWMBMA0GCSqGSIb3DQEBCwUAA4IBgQCt/a7A2Gikn+Wl -L6PnG04KOxVYrN1u3kUJ+nKNOqx4sKcjDLc91Xz4hiPHk0FWU2TVkwIOiITalTuN2RWBvzSd4vzL -z8YzDoWKsBYc525ph7/Tl9rGtOBD1l+aFMxLqSIY+tPme9ojl9uTU65xjaWDDe/VlxvNs2t5WWhY -WEGU9BaS2PPiKV9BrEfroqzLRjWO3z5Juon2cdZc2EUwQvIFM1LbaC9XnOjVm9yPVIw+GiAZbAbq -hGjyqW1KiP3zjaiVEeIAmtNP3vmlf3yYZYVqBcS0nW9+TdZa8RXiIDfzFrkW0YIYXEV8yo5e1PVs -/aey1ia/oU4ixmoyTppA6WfWvJzztc4OuCm8yQGH+evMP54xQbZlf4hI6AxUUb8RqCFYMchZAmyo -/vMcSmDrhROxbdB2AeqD/O8wWvV7+8tQCv3tY+sQ5ykV+egimiySXFmudx7SMdMXClaxQMY1tUQq -EB5UO/op0Dnph/nu43y8fgamClq+IliGqglrLGb7nuc= ------END CERTIFICATE----- diff --git a/deployment/test-out/phase3-1779199294.pem b/deployment/test-out/phase3-1779199294.pem deleted file mode 100644 index 8e46f223..00000000 --- a/deployment/test-out/phase3-1779199294.pem +++ /dev/null @@ -1,26 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIFOzCCA6OgAwIBAgIQWepnqmy9KsgZqSR4TzyewTANBgkqhkiG9w0BAQsFADBUMRwwGgYDVQQK -DBNsYWIgU2VjdXJpdHkgRG9tYWluMRMwEQYDVQQLDApwa2ktdG9tY2F0MR8wHQYDVQQDDBZDQSBT -aWduaW5nIENlcnRpZmljYXRlMB4XDTI2MDUxOTE0MDEzN1oXDTI3MDUxOTE0MDEzN1owbTELMAkG -A1UEBhMCTkwxGzAZBgNVBGEMElZBVE5MLTAwMDAwMDAwMEIwMTEkMCIGA1UECgwbQUNNRSBUZXN0 -IE9yZ2FuaXNhdGlvbiBCLlYuMRswGQYDVQQDDBJBQ01FIFJQIFNtb2tlIFRlc3QwggGiMA0GCSqG -SIb3DQEBAQUAA4IBjwAwggGKAoIBgQDBFZS5KFYpIdyX4ZTtsQzLjogFn/4vDiufSfBZsNNPIonr -6KQMXZtDVVqHnfInFnvPC7Qqxa5fTZtYSJ9TqWoZLYAW7gve0qHeQnFR3J/j0UUbZDaOsf278vGR -Ntzf50bwBZpk3+bZEDr5wwuaj5CnMPI2KxWCQbIYrcYlMvTcnqT9DAhjK+fyqXdHKWJFyMFtxLB0 -1zEyjVNWc8Nm0TarPUKTEj1UPd4HnYBZ+RNeG2HcZt8IJIOHWutHyXEppif29inUjHi5Y12QK0bC -qdXsBzJN1bq54aZMhoxV+RGCEw/qvouDmkaYjb1BMLdlaGPZtbnsEFt7IxrbzazP/GW5lA3fwqyW -/EMFOfDyqFpA5c906haQxocaVM/XYBTvLDYIdq5GtvLiEC8GoCv8vLZ+YiIkJ2uZ7qRdnS8bj5V3 -Ql1T/OtYdbByUn2qs8j6XnNW3EFgBvGOyWP+udEVxNRUP0hNFxhpZTUJIMhQ70YOqrI8I7ikCP1C -9Yuhjl1LYKsCAwEAAaOB7zCB7DAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwIG -CCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUf18K55dV2OtvskeidtD+avKzbEQw -PgYIKwYBBQUHAQEEMjAwMC4GCCsGAQUFBzABhiJodHRwOi8vbXctdm0tY2EwMS5sYWI6ODA4MC9j -YS9vY3NwMBYGA1UdEQQPMA2CC3JwLnRlc3QubGFiMB0GA1UdDgQWBBT2S6bifaMaCkiXFPbuvmuT -87DrEDAVBgNVHSAEDjAMMAoGCAQAi+xAAWMBMA0GCSqGSIb3DQEBCwUAA4IBgQBBn747ys4c6d8e -vA2LypslEfJMYpoGtjXdM3EH9w5yQ9SserUO0QID7G/oGlOesg9Nc/7M1VXW0hwFMzNySK4Ts61f -J1aJDxnBh1xDiwznLcdq3oUADfkp1Ymx1kEO7ld+YoGa/s5dBH8qxMT3qUFy0e8oR+BYJA5KlWOw -D6jy50rzh0ADb3mPeFh9AXnKXC9FwGC10lbm8uIkFFozjVmEdplqgewKBYDw4r8JzCanYe/c6i3h -TP5XzbWqGuovuhu4b+S6hoG7qVZ9rL7Ps06lSjIsS8Z8Fii8TsZRqLIpN7d+wbuNYwwvAyZksNbn -I8amT/dCjgRA7lNQmvTa9e4gBTmMpyPu7Nej1NozypaOf3BMBof/JxQbNJrCL1njdKKCCB7yl6w3 -GNF1M36IzBVcJSm2QA8ghw9ylUjQSl36Lz+f++HoPRahSytHHaiwydMJaLu+4IPxYiJfJ7cjoRjM -YbOzFOfWhRV9LnrbTN9iIXCATWzlo+osYwfMuWB1efs= ------END CERTIFICATE----- diff --git a/deployment/test-out/phase3-1779201344.pem b/deployment/test-out/phase3-1779201344.pem deleted file mode 100644 index 9136484c..00000000 --- a/deployment/test-out/phase3-1779201344.pem +++ /dev/null @@ -1,26 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIFPDCCA6SgAwIBAgIRANyJm8/0vB6+91U0hjps0CkwDQYJKoZIhvcNAQELBQAwVDEcMBoGA1UE -CgwTbGFiIFNlY3VyaXR5IERvbWFpbjETMBEGA1UECwwKcGtpLXRvbWNhdDEfMB0GA1UEAwwWQ0Eg -U2lnbmluZyBDZXJ0aWZpY2F0ZTAeFw0yNjA1MTkxNDM1NDZaFw0yNzA1MTkxNDM1NDZaMG0xCzAJ -BgNVBAYTAk5MMRswGQYDVQRhDBJWQVROTC0wMDAwMDAwMDBCMDExJDAiBgNVBAoMG0FDTUUgVGVz -dCBPcmdhbmlzYXRpb24gQi5WLjEbMBkGA1UEAwwSQUNNRSBSUCBTbW9rZSBUZXN0MIIBojANBgkq -hkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAuVrciqdCxSbNoAqmYqrcUMXFBAV8psOwzn9I5k3u+/hg -MrWq8d8hzXMFfoK7Vb3F3b2c9PXDiSuu32TsT4OzObWLPX4u0KftbUba7TrTw/0akhDI2MhhHuav -PxzWNcvv0o2/OG0lNEeQlVQ//eUr7mgcCQduUT/uI6QJZgEq7fcMzV2NQD27kQBgpa+JI101YgP9 -J0M6rKLwgbIs5W4GMbx+gepEJWnUQhwmeS9u4uwCDjlAy/IOUx5xjaYHy26q7zVYVAaSW4UscOmU -wZn+HVx5hTC5ZvPrs9F9fsAHzSeGStdtAK+IMxVaqiZ/m5F5MyYffwzPJmUqdDLaM6H9ovkx3M58 -oEWjy+oEZmRuSdDAe2+BeSkDjqnHmqnmcZwQr0OAKucXbTDX4cbwLb/aUffCc1J+VtSoTU4BU9rf -GIFMh40aQs2DjLrW/2XtqXVBrDrGHhM0vhai03Jh4NkZ8GspWRV+qf9xAOHCVl7EX3xuQaeq9lzE -NTxC2iYJejgXAgMBAAGjge8wgewwDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMC -BggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFH9fCueXVdjrb7JHonbQ/mrys2xE -MD4GCCsGAQUFBwEBBDIwMDAuBggrBgEFBQcwAYYiaHR0cDovL213LXZtLWNhMDEubGFiOjgwODAv -Y2Evb2NzcDAWBgNVHREEDzANggtycC50ZXN0LmxhYjAdBgNVHQ4EFgQUAOGlMlzjZD/rvKu1sAmH -iuF3IJQwFQYDVR0gBA4wDDAKBggEAIvsQAFjATANBgkqhkiG9w0BAQsFAAOCAYEAfYV3pF05Npxo -plhfQGf4APpcx/ll2RORy7afSI683QUnXXQ3AX+M96KbGHzJaVEPh2S1O8PjDgvLPt76lhEjrQoD -iUxnfqoSnujkdGV5Hdt0q7Tnq0HhJTfh+oTTEi19QJSw/ED49P1f8juBZxmBZqMNcDe5LdnRYDlg -RIXIvo7nzLlm0pUW5JRF7E8EX7IsvRqLPpUyfNpd3JPJmmBhCAagyxsEf6FM9Y3V2Mhwhjc14zTW -KiCSLdxvNFEUE6HxiVioosZmU/v2vsfnpJfxtmeY+R5adRzEUovnPuZtM3J7BI4d8v50nAqxvW/N -gsTJ+KLDFBBV7H+Lhbsi2UbNpl/5f6Gk76W5EZ5nABrvay07HFs1qsRoEcKc9fiE6bqzx3GYy2/t -IEe36Xkx4p5iFYEmOkl6a5CfxvRBQOcXOIHVVlxyig1ZYzXJfn1P5oBseMoutbVqDsumBvTL1IzD -qGGptFbKFACQfZtI1DaEtM5YMuFGnWJ0cs+/VQgZG5I+ ------END CERTIFICATE----- diff --git a/deployment/test-out/phase3-1779202392.pem b/deployment/test-out/phase3-1779202392.pem deleted file mode 100644 index 4815f015..00000000 --- a/deployment/test-out/phase3-1779202392.pem +++ /dev/null @@ -1,26 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIFPDCCA6SgAwIBAgIRAKdq2lYiKHzy52mbWc2bJ1EwDQYJKoZIhvcNAQELBQAwVDEcMBoGA1UE -CgwTbGFiIFNlY3VyaXR5IERvbWFpbjETMBEGA1UECwwKcGtpLXRvbWNhdDEfMB0GA1UEAwwWQ0Eg -U2lnbmluZyBDZXJ0aWZpY2F0ZTAeFw0yNjA1MTkxNDUzMTVaFw0yNzA1MTkxNDUzMTVaMG0xCzAJ -BgNVBAYTAk5MMRswGQYDVQRhDBJWQVROTC0wMDAwMDAwMDBCMDExJDAiBgNVBAoMG0FDTUUgVGVz -dCBPcmdhbmlzYXRpb24gQi5WLjEbMBkGA1UEAwwSQUNNRSBSUCBTbW9rZSBUZXN0MIIBojANBgkq -hkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAyP0pCC0JIVzOZaz/PvAXSnEqnhov6B8g9XCDXgUnj1nx -iA0d5aX6Jb14jpxJaWuiUp5C2zIBlaGjbHL/k5qbNRtE7yo71IQsf7IiMuljeAV/YRTrwDSlaEMJ -bZ/Lyj+Ou4tcVAKdqHZ4YX9LQ7u/1wFgu0smch6aCY4SBRs17RFN3jfLElYxSr3XZUH5UIY2Krrd -KzE/xDJfBwbvZBzhxexrRYFoW6fZP1u/cIoxSQ3DENNOpBi/hR85QbVzz2vTzeBNFMY/h9jktAuh -dnlPxa0xTXg1RGgUb0Tn9VyrnGP8pg/FyQXvOBF8lrUhw5VqrZJvEzXjVSbKshnW6iSKcM+EE1cY -wCCFtxBCLLXX0i4kqvPU1nKQZaf0uOTR8rFN9Mu/Z1wexmj5XTXl9RoqeG4kIqUO5W7hWDnd76+s -AM0ZwVZwOv353KvmoUQok4LCrhjKdOoUZHP1ZeL5CoCBX1Hq6B6JaJ9MpnDg3HjX8bNilFNqQ2wE -2zQAa3RwsQpDAgMBAAGjge8wgewwDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMC -BggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFH9fCueXVdjrb7JHonbQ/mrys2xE -MD4GCCsGAQUFBwEBBDIwMDAuBggrBgEFBQcwAYYiaHR0cDovL213LXZtLWNhMDEubGFiOjgwODAv -Y2Evb2NzcDAWBgNVHREEDzANggtycC50ZXN0LmxhYjAdBgNVHQ4EFgQUUbCiie4zcu6nLz3lzDGp -1V8HnqIwFQYDVR0gBA4wDDAKBggEAIvsQAFjATANBgkqhkiG9w0BAQsFAAOCAYEAWoYIblGoBwJX -4kGjiJdG7ytkiTg6/e8Q4N2BVqaNOevzNBwR7qts+ZwxcCtlSFb+MJypXr3ma1WOR787iTxHlrLL -5ztmkalWeoJNWbqce3FkvqDWT6PcptxGrnm3PHFOMqeoOy2tz2GzTov+5dIkgMxMFp7Xb7C8ejXy -E/VH7w6DRxJUCH1bWuuVnLtMjRY2VKeNi6W2uU9o4wqTiIuQiFyIykdyybC81fMlvu1zlwzeXB87 -XSp+i7xM3EIwDYh5PUp7hWBGM0I0sruiEn6GoVASWr47nwqFq6hNSLFkklrAgEgMurf6aZ/GR72g -Uv0Z/LO4HLhCZTBTr176MwkRaSaCVQibIEUtL9SOLpYFJsiNxJrunEQ6U/R5GEdj7yuGownl0bpC -XcIN4W5YdxHZ831wUR3WeH5R3U+UcwtwGP0ZeIBF8rH/bHvn5VN+kwEGJb3NewYzanVcJ9wK2m4o -rS7iK401Psi6d0M1gyblTyckdbYRk84CNUrCMydXV/l/ ------END CERTIFICATE----- diff --git a/deployment/test-out/profile-check.pem b/deployment/test-out/profile-check.pem deleted file mode 100644 index 45dda551..00000000 --- a/deployment/test-out/profile-check.pem +++ /dev/null @@ -1,26 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIFOjCCA6KgAwIBAgIQZFkZW3rZeZdXY2DS/H2gmDANBgkqhkiG9w0BAQsFADBUMRwwGgYDVQQK -DBNsYWIgU2VjdXJpdHkgRG9tYWluMRMwEQYDVQQLDApwa2ktdG9tY2F0MR8wHQYDVQQDDBZDQSBT -aWduaW5nIENlcnRpZmljYXRlMB4XDTI2MDUxOTE0NTMyMloXDTI3MDUxOTE0NTMyMlowbDELMAkG -A1UEBhMCTkwxGzAZBgNVBGEMElZBVE5MLTAwMDAwMDAwMEIwMTEkMCIGA1UECgwbQUNNRSBUZXN0 -IE9yZ2FuaXNhdGlvbiBCLlYuMRowGAYDVQQDDBFDUy0wNiDCpzcuNCBjaGVjazCCAaIwDQYJKoZI -hvcNAQEBBQADggGPADCCAYoCggGBAMVtDZPv5LF+Walxx5ZKWSAdUiLyPGBewN/z4ChSPtrmSGkZ -YqTYOHUsDzfM+ftWu5hyq4QFn5wA5tyhl/A+4I4fQXZH8psxgYdpoVUMQ1qj37vX5uIcMcy3El5+ -ISSHm/zHCBXnMk4U0QALq3ihHt+evL1S4OIqKpyTa4CYjtCH2M9qE47uNKDke+iEWH2WUo+XlD6/ -wRhmtyLtzMcIT5gSJ7CQ6iG8WjQe1B8hSlvll/3glsUHU4wHciTjLsXmTDyl9rtadTsjOc6CCaKB -VtxOnDGwkiTYBlF8Ae5imk+h3+GJJYG3dzoweaDYU4RiCZj18nAhjaFy3ZehwzEmR2/5R492HWhu -V9Iuk0H/2FysqrETdGGtbgch7bnDRDgLwhVXKcRsKAXZoWRqxqnP4/DaFxREwMTL1c79gEiGXpSQ -zvPXEUwmA5/q4gVb+UjbDdWbn1B7+B9NuUJ5qOWzDiBMwaZPJwsbcmgQoGl3HSFsWZEbJRQuWUGb -og40tveKQwIDAQABo4HvMIHsMA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAgYI -KwYBBQUHAwEwDAYDVR0TAQH/BAIwADAfBgNVHSMEGDAWgBR/Xwrnl1XY62+yR6J20P5q8rNsRDA+ -BggrBgEFBQcBAQQyMDAwLgYIKwYBBQUHMAGGImh0dHA6Ly9tdy12bS1jYTAxLmxhYjo4MDgwL2Nh -L29jc3AwFgYDVR0RBA8wDYILcnAudGVzdC5sYWIwHQYDVR0OBBYEFILr1rHaQksFsUGiiuzpdDzA -vIklMBUGA1UdIAQOMAwwCgYIBACL7EABYwEwDQYJKoZIhvcNAQELBQADggGBAHWDMH6Zg7F7viAJ -VHyuwiDIC7LyLar7CFAZBUwjufnTmNUoP6T178d6qYlA6CXM7fx6o+0M7nGFORZcFThmCFBxcnpf -MwFJHVmqw32hOJVJaTCDqhT8Y1vek5cfp9nmJ+N8WA91Jh1dIehTWGjF+3zV8kOEa73jBEH71PGO -5JaxWcZl4+eLS86lD3xe6p4yQY9md0Yt74S5kRhYzDYszw/CTrxc3L9g3RxFjFzfRa8ll7nFZJHo -KUoQZU+SsVN9frKf73iBRqNVhJRceBNGy2lmBO+jyITFR3fiGFv9d49A+kjjTs2W2eLk/cY/wKZq -vFMF8kFydSrB/duLjNERESF27FW0PWGtBc1TUqfGXBIDV82Yx5OXwRAjy6GK1vC/ZkfrOVnjWgrm -JvSqhVsKPHDLI4GLTxy52nEVJWrTngcwWIyzWtg2TVal2h6KIokYm4mcNEokzngVNw56Ihw2R0An -BBR2wcBAzGqAgPqFk89KxXlV2yu2F5Tr5J5odw2BcQ== ------END CERTIFICATE----- diff --git a/deployment/tests/README.md b/deployment/tests/README.md deleted file mode 100644 index e322b05e..00000000 --- a/deployment/tests/README.md +++ /dev/null @@ -1,45 +0,0 @@ -# Tests - -This directory holds the test suite for the CS-06 CA deployment. Each test is a -standalone ansible playbook that runs on the CA host (`mw-vm-ca01.lab`) and can -target either the local or the public ACME endpoints via a single variable. - -## Test matrix - -| Test | What it covers | Targetable URL | -|---|---|---| -| `test_01_directory.yml` | Both ACME directories + RP List service respond and advertise the expected URLs/metadata | local **or** public | -| `test_02_phase1_acme.yml` | Stock RFC 8555 ACME issuance via `acme.sh` http-01 (Dogtag's built-in responder) | **local only** — needs the validator to reach back to the client | -| `test_03_phase2_direct.yml` | Direct CA enrolment with `pki ca-cert-request-submit --profile wrpacCert` and a WRPAC-shaped CSR | **local only** | -| `test_04_phase3_happy.yml` | Full CS-06 §6 happy path: `wrp-id` order → `registrar-api-01` challenge → finalize → cert download | local **or** public | -| `test_05_phase3_negative.yml` | Negative-test battery for CS-06 §7.2 server MUST clauses (missing EAB, unknown wrp-id, wrong key-auth, Subject mismatch, etc.) | local **or** public | -| `test_06_cert_profile.yml` | Parses a freshly-issued WRPAC and asserts every CS-06 §7.4 attribute | local **or** public | -| `test_07_persistence.yml` | Registers an ACME account, restarts `pki-tomcatd`, re-issues with the same account — proves the `ds` backend persists state | **local only** (uses acme.sh + systemctl) | -| `test_08_facade_persistence.yml` | Snapshots façade SQLite row counts, restarts `wrpac-acme-facade` + `wrpac-rp-list`, asserts counts unchanged — proves the Phase 3 façade & RP List persist state | **local only** (uses sqlite3 + systemctl) | - -## Run from `deployment/` (one level up) - -```sh -. .venv/bin/activate - -# Local (internal) URL — default -ansible-playbook tests/test_all.yml - -# Public URL -ansible-playbook tests/test_all.yml \ - -e test_acme_directory=https://rpca.lab.cleverbase.io/acme-eudi-wrpac/directory \ - -e test_rp_list=https://rpca.lab.cleverbase.io \ - -e test_acme_stock_directory=https://rpca.lab.cleverbase.io/acme/directory - -# Single test -ansible-playbook tests/test_05_phase3_negative.yml -e test_acme_directory=... -``` - -## Variables - -| Var | Default | Used by | -|---|---|---| -| `test_acme_directory` | `http://{{ ca_fqdn }}:{{ wrpac_facade_port }}/acme-eudi-wrpac/directory` | tests 1, 4, 5, 6 | -| `test_rp_list` | `http://{{ ca_fqdn }}:{{ wrpac_rp_list_port }}` | tests 1, 4, 5, 6 | -| `test_acme_stock_directory` | `https://{{ ca_fqdn }}:8443/acme/directory` | test 1 | -| `test_out_dir` | `/root/test-out` | tests 4, 6 | diff --git a/deployment/tests/test_01_directory.yml b/deployment/tests/test_01_directory.yml deleted file mode 100644 index f4b25f0c..00000000 --- a/deployment/tests/test_01_directory.yml +++ /dev/null @@ -1,82 +0,0 @@ ---- -# Test 01 — directory + nonce + RP List sanity. -# Verifies all three public surfaces respond and advertise the expected URLs. - -- name: "Test 01: directories + RP List sanity" - hosts: localhost - connection: local - gather_facts: false - vars: - test_acme_directory: "http://{{ ca_fqdn }}:{{ wrpac_facade_port }}/acme-eudi-wrpac/directory" - test_rp_list: "http://{{ ca_fqdn }}:{{ wrpac_rp_list_port }}" - test_acme_stock_directory: "https://{{ ca_fqdn }}:8443/acme/directory" - - tasks: - - - name: GET stock Dogtag ACME directory - ansible.builtin.uri: - url: "{{ test_acme_stock_directory }}" - validate_certs: false - return_content: true - register: stock_dir - - - name: Assert stock ACME directory carries the RFC 8555 endpoints - ansible.builtin.assert: - that: - - stock_dir.status == 200 - - stock_dir.json.newNonce is defined - - stock_dir.json.newAccount is defined - - stock_dir.json.newOrder is defined - - stock_dir.json.revokeCert is defined - fail_msg: "stock ACME directory missing required endpoints" - - - name: GET CS-06 façade directory - ansible.builtin.uri: - url: "{{ test_acme_directory }}" - validate_certs: false - return_content: true - register: facade_dir - - - name: Assert façade directory advertises CS-06 §5 extensions - ansible.builtin.assert: - that: - - facade_dir.status == 200 - - "'wrp-id' in facade_dir.json.meta.supportedIdentifierTypes" - - "'registrar-api-01' in facade_dir.json.meta.supportedChallengeTypes" - - facade_dir.json.meta.externalAccountRequired - fail_msg: "façade directory missing CS-06 §5 markers" - - - name: Façade newNonce returns a Replay-Nonce header - ansible.builtin.uri: - url: "{{ facade_dir.json.newNonce }}" - method: HEAD - validate_certs: false - status_code: [200, 204] - register: facade_nonce - - - name: Assert Replay-Nonce header present - ansible.builtin.assert: - that: - - "'replay-nonce' in (facade_nonce | dict2items | map(attribute='key') | list) or 'Replay-Nonce' in facade_nonce or facade_nonce.replay_nonce is defined" - fail_msg: "newNonce did not return a Replay-Nonce header" - ignore_errors: true # Header capture varies across ansible versions; the uri 200 already proves it works - - - name: GET seeded RP List entry - ansible.builtin.uri: - url: "{{ test_rp_list }}/rp-list/{{ wrpac_test_wrp_id }}" - validate_certs: false - return_content: true - register: rp_entry - - - name: Assert RP List entry matches expected identity - ansible.builtin.assert: - that: - - rp_entry.json.wrp_id == wrpac_test_wrp_id - - rp_entry.json.legal_name == wrpac_test_organisation_name - - rp_entry.json.organization_identifier == wrpac_test_organisation_identifier - - rp_entry.json.country == wrpac_test_country - - rp_entry.json.status == "active" - - - name: "Test 01 passed" - ansible.builtin.debug: - msg: "Directories and RP List sanity OK" diff --git a/deployment/tests/test_02_phase1_acme.yml b/deployment/tests/test_02_phase1_acme.yml deleted file mode 100644 index 3cb9d4af..00000000 --- a/deployment/tests/test_02_phase1_acme.yml +++ /dev/null @@ -1,61 +0,0 @@ ---- -# Test 02 — Phase 1 stock ACME issuance via acme.sh http-01. -# Local-only: the http-01 validator (Dogtag) must reach back to the client's :80 -# listener, so this won't work through a hairpinned public URL. - -- name: "Test 02: Phase 1 stock ACME http-01" - hosts: all - gather_facts: false - vars: - acme_dir: "https://{{ ca_fqdn }}:8443/acme/directory" - - tasks: - - - name: Ensure /root/.acme.sh exists (test setup ran) - ansible.builtin.stat: - path: /root/.acme.sh/acme.sh - register: acme_sh_present - - - name: Skip if acme.sh isn't installed (run playbooks/99-smoke-test.yml first) - ansible.builtin.meta: end_play - when: not acme_sh_present.stat.exists - - - name: Clear acme.sh's cached account (Dogtag's ACME state is in-memory and resets across pki-tomcatd restarts) - ansible.builtin.shell: | - rm -rf /root/.acme.sh/ca/* /root/.acme.sh/account.key 2>/dev/null || true - changed_when: false - - - name: Re-issue a stock ACME cert via standalone http-01 - ansible.builtin.command: > - /root/.acme.sh/acme.sh - --server {{ acme_dir }} - --issue - --standalone - --insecure - --keylength 2048 - -d {{ ca_fqdn }} - --force - register: acme_issue - changed_when: true - - - name: Assert issuance succeeded - ansible.builtin.assert: - that: - - acme_issue.rc == 0 - - "'Success' in acme_issue.stdout" - - - name: Inspect issued cert - ansible.builtin.command: > - openssl x509 -in /root/.acme.sh/{{ ca_fqdn }}/{{ ca_fqdn }}.cer -noout -subject -issuer -nameopt RFC2253 - register: stock_cert - changed_when: false - - - name: Assert stock cert has expected DNS Subject and our CA Issuer - ansible.builtin.assert: - that: - - "'CN=' + ca_fqdn in stock_cert.stdout" - - "'CA Signing Certificate' in stock_cert.stdout" - - - name: "Test 02 passed" - ansible.builtin.debug: - msg: "{{ stock_cert.stdout_lines }}" diff --git a/deployment/tests/test_03_phase2_direct.yml b/deployment/tests/test_03_phase2_direct.yml deleted file mode 100644 index 29ebc07e..00000000 --- a/deployment/tests/test_03_phase2_direct.yml +++ /dev/null @@ -1,67 +0,0 @@ ---- -# Test 03 — Phase 2 direct CA enrolment with the wrpacCert profile. -# Local-only: needs the caadmin NSS DB on the box. - -- name: "Test 03: Phase 2 direct enrolment" - hosts: all - gather_facts: false - vars: - smoke_dir: /root/wrpac-smoke - nss_dir: "/root/wrpac-smoke/nssdb" - nss_password: Secret.123 - csr_file: "/root/wrpac-smoke/wrpac.csr" - cert_out: "/root/test-out/phase2-direct.pem" - admin_nickname: "PKI Administrator for lab" - - tasks: - - - name: Ensure the Phase 2 NSS DB is present - ansible.builtin.stat: - path: "{{ nss_dir }}/cert9.db" - register: nss_present - - - name: Skip if Phase 2 hasn't been deployed - ansible.builtin.meta: end_play - when: not nss_present.stat.exists - - - name: Ensure output dir - ansible.builtin.file: - path: /root/test-out - state: directory - owner: root - mode: "0700" - - - name: Submit a fresh CSR with the wrpacCert profile - ansible.builtin.command: > - pki -d {{ nss_dir }} -c {{ nss_password }} - -n "{{ admin_nickname }}" - -U https://{{ ca_fqdn }}:8443 - ca-cert-request-submit - --profile {{ wrpac_profile_id }} - --csr-file {{ csr_file }} - register: submit - changed_when: true - - - name: Assert auto-issue (agent submitter → complete) - ansible.builtin.assert: - that: - - "'Request Status: complete' in submit.stdout" - - "'Operation Result: success' in submit.stdout" - - "'Certificate ID:' in submit.stdout" - - - name: Capture Certificate ID - ansible.builtin.set_fact: - cert_id: "{{ submit.stdout | regex_search('Certificate ID:\\s*(\\S+)', '\\1') | first }}" - - - name: Export issued cert - ansible.builtin.command: > - pki -d {{ nss_dir }} -c {{ nss_password }} - -n "{{ admin_nickname }}" - -U https://{{ ca_fqdn }}:8443 - ca-cert-show {{ cert_id }} - --output {{ cert_out }} - changed_when: true - - - name: "Test 03 passed — cert at {{ cert_out }}" - ansible.builtin.debug: - msg: "Certificate ID {{ cert_id }} exported to {{ cert_out }}" diff --git a/deployment/tests/test_04_phase3_happy.yml b/deployment/tests/test_04_phase3_happy.yml deleted file mode 100644 index 9a1b036c..00000000 --- a/deployment/tests/test_04_phase3_happy.yml +++ /dev/null @@ -1,43 +0,0 @@ ---- -# Test 04 — Phase 3 CS-06 §6 happy path via the Python ACME client. -# Targets either local or public URL via test_acme_directory / test_rp_list. - -- name: "Test 04: Phase 3 CS-06 happy path" - hosts: localhost - connection: local - gather_facts: false - vars: - test_acme_directory: "http://{{ ca_fqdn }}:{{ wrpac_facade_port }}/acme-eudi-wrpac/directory" - test_rp_list: "http://{{ ca_fqdn }}:{{ wrpac_rp_list_port }}" - test_python_bin: "{{ playbook_dir }}/../.venv/bin/python" - test_out_dir: "{{ playbook_dir }}/../test-out" - cert_out: "{{ test_out_dir }}/phase3-{{ lookup('pipe', 'date +%s') }}.pem" - - tasks: - - - name: Ensure output dir - ansible.builtin.file: - path: "{{ test_out_dir }}" - state: directory - mode: "0755" - - - name: Run the Phase 3 client - ansible.builtin.command: > - {{ test_python_bin }} -m wrpac_acme.client - --directory {{ test_acme_directory }} - --rp-list {{ test_rp_list }} - --wrp-id {{ wrpac_test_wrp_id }} - --legal-name "{{ wrpac_test_organisation_name }}" - --org-id {{ wrpac_test_organisation_identifier }} - --country {{ wrpac_test_country }} - --friendly-name "{{ wrpac_test_friendly_name }}" - --out-cert {{ cert_out }} - register: client_run - - - name: Tail of client output - ansible.builtin.debug: - msg: "{{ client_run.stdout_lines[-12:] }}" - - - name: Set cert_out fact for downstream tests - ansible.builtin.set_fact: - phase3_cert: "{{ cert_out }}" diff --git a/deployment/tests/test_05_phase3_negative.yml b/deployment/tests/test_05_phase3_negative.yml deleted file mode 100644 index bf93768a..00000000 --- a/deployment/tests/test_05_phase3_negative.yml +++ /dev/null @@ -1,31 +0,0 @@ ---- -# Test 05 — CS-06 §7.2 negative-test battery against the Phase 3 façade. - -- name: "Test 05: Phase 3 negative tests" - hosts: localhost - connection: local - gather_facts: false - vars: - test_acme_directory: "http://{{ ca_fqdn }}:{{ wrpac_facade_port }}/acme-eudi-wrpac/directory" - test_rp_list: "http://{{ ca_fqdn }}:{{ wrpac_rp_list_port }}" - test_python_bin: "{{ playbook_dir }}/../.venv/bin/python" - - tasks: - - name: Run the negative-test battery - ansible.builtin.command: > - {{ test_python_bin }} -m wrpac_acme.negative_tests - --directory {{ test_acme_directory }} - --rp-list {{ test_rp_list }} - register: neg - changed_when: false - - - name: Show negative-test report - ansible.builtin.debug: - msg: "{{ neg.stdout_lines }}" - - - name: Assert all negative tests passed - ansible.builtin.assert: - that: - - "'failed' in neg.stdout" - - "'0 failed' in neg.stdout" - fail_msg: "Negative-test battery reports failures — see output above" diff --git a/deployment/tests/test_06_cert_profile.yml b/deployment/tests/test_06_cert_profile.yml deleted file mode 100644 index 109f8123..00000000 --- a/deployment/tests/test_06_cert_profile.yml +++ /dev/null @@ -1,120 +0,0 @@ ---- -# Test 06 — CS-06 §7.4 attribute assertions on a freshly-issued WRPAC. -# -# Issues one cert via the Phase 3 client (against either local or public URL) -# and asserts every §7.4 attribute. This is the most useful single test for -# verifying the cert profile end-to-end. - -- name: "Test 06: CS-06 §7.4 attributes" - hosts: localhost - connection: local - gather_facts: false - vars: - test_acme_directory: "http://{{ ca_fqdn }}:{{ wrpac_facade_port }}/acme-eudi-wrpac/directory" - test_rp_list: "http://{{ ca_fqdn }}:{{ wrpac_rp_list_port }}" - test_python_bin: "{{ playbook_dir }}/../.venv/bin/python" - test_out_dir: "{{ playbook_dir }}/../test-out" - cert_path: "{{ test_out_dir }}/profile-check.pem" - ca_root_path: "{{ test_out_dir }}/dogtag-ca-root.pem" - - tasks: - - - name: Ensure output dir - ansible.builtin.file: - path: "{{ test_out_dir }}" - state: directory - mode: "0755" - - - name: Fetch the Dogtag CA root via SSH (needed for chain verification) - ansible.builtin.shell: | - ssh cbadmin@{{ ca_fqdn }} 'sudo cat /root/wrpac-smoke/ca_signing.crt' > {{ ca_root_path }} - test -s {{ ca_root_path }} - args: - creates: "{{ ca_root_path }}" - - - name: Issue a fresh cert via the Phase 3 client - ansible.builtin.command: > - {{ test_python_bin }} -m wrpac_acme.client - --directory {{ test_acme_directory }} - --rp-list {{ test_rp_list }} - --wrp-id {{ wrpac_test_wrp_id }} - --legal-name "{{ wrpac_test_organisation_name }}" - --org-id {{ wrpac_test_organisation_identifier }} - --country {{ wrpac_test_country }} - --friendly-name "CS-06 §7.4 check" - --out-cert {{ cert_path }} - changed_when: true - - - name: Dump cert (text + extensions) - ansible.builtin.command: > - openssl x509 -in {{ cert_path }} -noout -text -nameopt RFC2253 - register: cert_text - changed_when: false - - - name: "§7.4 #1 — X.509 v3" - ansible.builtin.assert: - that: "'Version: 3' in cert_text.stdout" - - - name: "§7.4 #3 — legal name in Subject (O=)" - ansible.builtin.assert: - that: "'O=' + wrpac_test_organisation_name in cert_text.stdout" - - - name: "§7.4 #5 — unique WRP identifier (organizationIdentifier / 2.5.4.97)" - ansible.builtin.assert: - that: - - "'organizationIdentifier=' + wrpac_test_organisation_identifier in cert_text.stdout or '2.5.4.97=' + wrpac_test_organisation_identifier in cert_text.stdout" - - - name: "§7.4 #6 — certificate policy OID present" - ansible.builtin.assert: - that: wrpac_policy_oid_legal in cert_text.stdout - - - name: "§7.4 #7 — certification path info URI (AIA)" - ansible.builtin.assert: - that: "'Authority Information Access' in cert_text.stdout" - - - name: "§7.4 #8 — SAN with DNS or URI" - ansible.builtin.assert: - that: - - "'Subject Alternative Name' in cert_text.stdout" - - "'DNS:' in cert_text.stdout or 'URI:' in cert_text.stdout" - - - name: "§7.4 #12 — SHA-256+ signature, RSA-3072+ or ECDSA P-256/384" - ansible.builtin.assert: - that: - - "'sha256' in cert_text.stdout or 'sha384' in cert_text.stdout or 'sha512' in cert_text.stdout" - - "'Public-Key: (3072 bit)' in cert_text.stdout or 'Public-Key: (4096 bit)' in cert_text.stdout or 'NIST CURVE: P-256' in cert_text.stdout or 'NIST CURVE: P-384' in cert_text.stdout" - - - name: "§7.4 #13 — 1-year validity" - ansible.builtin.shell: | - set -e -o pipefail - not_before=$(openssl x509 -in {{ cert_path }} -noout -startdate | cut -d= -f2) - not_after=$(openssl x509 -in {{ cert_path }} -noout -enddate | cut -d= -f2) - nb_epoch=$(date -d "$not_before" +%s) - na_epoch=$(date -d "$not_after" +%s) - days=$(( (na_epoch - nb_epoch) / 86400 )) - echo "validity days: $days" - test "$days" -ge 360 && test "$days" -le 366 - args: - executable: /bin/bash - register: validity_check - changed_when: false - - - name: "Show validity check" - ansible.builtin.debug: - msg: "{{ validity_check.stdout }}" - - - name: "Chain verifies against the Dogtag root" - ansible.builtin.command: > - openssl verify -CAfile {{ ca_root_path }} {{ cert_path }} - register: chain - changed_when: false - - - name: "Assert chain OK" - ansible.builtin.assert: - that: "'OK' in chain.stdout" - - - name: "Test 06 passed — cert at {{ cert_path }} meets CS-06 §7.4" - ansible.builtin.debug: - msg: - - "{{ chain.stdout }}" - - "{{ validity_check.stdout }}" diff --git a/deployment/tests/test_07_persistence.yml b/deployment/tests/test_07_persistence.yml deleted file mode 100644 index 38467482..00000000 --- a/deployment/tests/test_07_persistence.yml +++ /dev/null @@ -1,86 +0,0 @@ ---- -# Test 07 — ACME state survives pki-tomcatd restart. -# -# 1. Clear acme.sh's local account cache so we start fresh -# 2. Register an account by issuing a cert (acme.sh) -# 3. Restart pki-tomcatd -# 4. Issue another cert with the SAME local account state -# -# With the in-memory backend, step 4 fails (accountDoesNotExist). -# With the ds backend, step 4 succeeds. - -- name: "Test 07: persistence — ACME state survives pki-tomcatd restart" - hosts: all - gather_facts: false - vars: - acme_dir: "https://{{ ca_fqdn }}:8443/acme/directory" - - tasks: - - - name: Skip if acme.sh isn't installed - ansible.builtin.stat: - path: /root/.acme.sh/acme.sh - register: acme_sh - - - name: Skip play - ansible.builtin.meta: end_play - when: not acme_sh.stat.exists - - - name: Clear acme.sh state for a clean baseline - ansible.builtin.shell: | - rm -rf /root/.acme.sh/ca/* /root/.acme.sh/account.key 2>/dev/null || true - changed_when: false - - - name: Issue baseline cert (registers account) - ansible.builtin.command: > - /root/.acme.sh/acme.sh - --server {{ acme_dir }} - --issue --standalone --insecure --keylength 2048 - -d {{ ca_fqdn }} --force - register: baseline_issue - changed_when: true - - - name: Assert baseline issuance succeeded - ansible.builtin.assert: - that: - - baseline_issue.rc == 0 - - "'Success' in baseline_issue.stdout" - - - name: Restart pki-tomcatd to test state persistence - ansible.builtin.systemd: - name: "pki-tomcatd@{{ pki_instance_name }}" - state: restarted - - - name: Wait for ACME directory after restart - ansible.builtin.uri: - url: "{{ acme_dir }}" - validate_certs: false - register: post_restart_dir - retries: 12 - delay: 5 - until: post_restart_dir.status == 200 - - - name: Re-issue cert with the SAME account (should work iff state persisted) - ansible.builtin.command: > - /root/.acme.sh/acme.sh - --server {{ acme_dir }} - --issue --standalone --insecure --keylength 2048 - -d {{ ca_fqdn }} --force - register: post_restart_issue - changed_when: true - - - name: Assert post-restart issuance succeeded (proves persistence) - ansible.builtin.assert: - that: - - post_restart_issue.rc == 0 - - "'Success' in post_restart_issue.stdout" - - "'accountDoesNotExist' not in post_restart_issue.stderr" - - "'accountDoesNotExist' not in post_restart_issue.stdout" - fail_msg: | - ACME state did not survive pki-tomcatd restart. - stderr: {{ post_restart_issue.stderr | default('(empty)') }} - stdout tail: {{ post_restart_issue.stdout_lines[-5:] | default([]) }} - - - name: "Test 07 passed — account survived restart with backend {{ acme_database_backend }}" - ansible.builtin.debug: - msg: "Account persistence verified" diff --git a/deployment/tests/test_08_facade_persistence.yml b/deployment/tests/test_08_facade_persistence.yml deleted file mode 100644 index dd47ad78..00000000 --- a/deployment/tests/test_08_facade_persistence.yml +++ /dev/null @@ -1,111 +0,0 @@ ---- -# Test 08 — CS-06 façade state survives wrpac-acme-facade restart. -# -# 1. Issue a cert via Phase 3 client → creates account + order + cert rows -# 2. Snapshot SQLite row counts before restart -# 3. Restart wrpac-acme-facade -# 4. Snapshot SQLite row counts after restart -# 5. Assert: counts > 0 and unchanged - -- name: "Test 08: façade persistence — state survives wrpac-acme-facade restart" - hosts: all - gather_facts: false - vars: - db: /var/lib/wrpac-acme/facade.sqlite3 - rp_db: /var/lib/wrpac-acme/rp_list.sqlite3 - acme_dir: "http://{{ ca_fqdn }}:{{ wrpac_facade_port }}/acme-eudi-wrpac/directory" - rp_list_url: "http://{{ ca_fqdn }}:{{ wrpac_rp_list_port }}" - - tasks: - - - name: Ensure sqlite3 CLI is available - ansible.builtin.dnf: - name: sqlite - state: present - - - name: Issue a fresh cert via Phase 3 client (populates SQLite) - ansible.builtin.command: > - {{ wrpac_facade_root }}/.venv/bin/python -m wrpac_acme.client - --directory {{ acme_dir }} - --rp-list {{ rp_list_url }} - --wrp-id {{ wrpac_test_wrp_id }} - --legal-name "{{ wrpac_test_organisation_name }}" - --org-id {{ wrpac_test_organisation_identifier }} - --country {{ wrpac_test_country }} - --friendly-name "Test 08 persistence" - --out-cert /tmp/test_08.pem - changed_when: true - - - name: Snapshot row counts before restart - ansible.builtin.shell: | - set -e - echo "accounts=$(sqlite3 {{ db }} 'SELECT COUNT(*) FROM accounts')" - echo "orders=$(sqlite3 {{ db }} 'SELECT COUNT(*) FROM orders')" - echo "authzs=$(sqlite3 {{ db }} 'SELECT COUNT(*) FROM authzs')" - echo "challenges=$(sqlite3 {{ db }} 'SELECT COUNT(*) FROM challenges')" - echo "certs=$(sqlite3 {{ db }} 'SELECT COUNT(*) FROM certs')" - echo "rp_entries=$(sqlite3 {{ rp_db }} 'SELECT COUNT(*) FROM entries')" - args: - executable: /bin/bash - register: before - changed_when: false - - - name: Show pre-restart snapshot - ansible.builtin.debug: - msg: "{{ before.stdout_lines }}" - - - name: Restart wrpac-acme-facade + wrpac-rp-list - ansible.builtin.systemd: - name: "{{ item }}" - state: restarted - loop: - - wrpac-rp-list - - wrpac-acme-facade - - - name: Wait for façade to come back - ansible.builtin.wait_for: - host: 127.0.0.1 - port: "{{ wrpac_facade_port }}" - timeout: 30 - - - name: Snapshot row counts after restart - ansible.builtin.shell: | - set -e - echo "accounts=$(sqlite3 {{ db }} 'SELECT COUNT(*) FROM accounts')" - echo "orders=$(sqlite3 {{ db }} 'SELECT COUNT(*) FROM orders')" - echo "authzs=$(sqlite3 {{ db }} 'SELECT COUNT(*) FROM authzs')" - echo "challenges=$(sqlite3 {{ db }} 'SELECT COUNT(*) FROM challenges')" - echo "certs=$(sqlite3 {{ db }} 'SELECT COUNT(*) FROM certs')" - echo "rp_entries=$(sqlite3 {{ rp_db }} 'SELECT COUNT(*) FROM entries')" - args: - executable: /bin/bash - register: after - changed_when: false - - - name: Show post-restart snapshot - ansible.builtin.debug: - msg: "{{ after.stdout_lines }}" - - - name: Assert row counts non-zero and unchanged - ansible.builtin.assert: - that: - - after.stdout == before.stdout - - "'accounts=0' not in after.stdout" - - "'orders=0' not in after.stdout" - - "'certs=0' not in after.stdout" - - "'rp_entries=0' not in after.stdout" - fail_msg: | - State did NOT survive restart. - before: - {{ before.stdout }} - after: - {{ after.stdout }} - - - name: Sanity-check we can still GET the directory (façade is healthy) - ansible.builtin.uri: - url: "{{ acme_dir }}" - register: post_restart_dir - - - name: "Test 08 passed — accounts/orders/certs survived restart" - ansible.builtin.debug: - msg: "Façade persistence verified." diff --git a/deployment/tests/test_all.yml b/deployment/tests/test_all.yml deleted file mode 100644 index 8e506ecf..00000000 --- a/deployment/tests/test_all.yml +++ /dev/null @@ -1,12 +0,0 @@ ---- -# Master test playbook — runs every test in order. Pass URL overrides via -e -# to target the public deployment instead of the local one. See tests/README.md. - -- import_playbook: test_01_directory.yml -- import_playbook: test_02_phase1_acme.yml -- import_playbook: test_03_phase2_direct.yml -- import_playbook: test_04_phase3_happy.yml -- import_playbook: test_05_phase3_negative.yml -- import_playbook: test_06_cert_profile.yml -- import_playbook: test_07_persistence.yml -- import_playbook: test_08_facade_persistence.yml From 4147bf588369fa79498ba93e71db839254a44af4 Mon Sep 17 00:00:00 2001 From: FCHASSERY Date: Fri, 31 Jul 2026 17:49:31 +0200 Subject: [PATCH 09/14] docs: RPAC and RPRC conformance spec revision This version aims to stay as little as possible specific to WE BUILD but keep the simplicity brought by utilisation of ACME protocol carrying WRP data. Particularly it : - Limits adaptation of ACME Protocol, both for server and client; - Separates clearly registration of RP from certificate issuance process; - Give up RP's writing in the Trust List; - Specifies certificate profile; - Precises instanceID positionment in certificate profile; - Deletes automatic revocation in case of new issuance of an RPAC; - Precises RA's actions and revocation timeframe in case of suppression of RP's entry in the Trust List; - Repositions RPRC optional issuance sooner, just after registration; - Deletes simultaneity between RPRC issuance and RPAC issuance Original commits: https://github.com/jillesvo/wp4-architecture/pull/1 Original pull request: https://github.com/FCHASSERY/wp4-architecture/pull/1 Amendment by Sander: change into a single change to the original file. Co-authored-by: Sander Dijkhuis --- ...ty-access-and-registration-certificates.md | 1363 ++++++++--------- 1 file changed, 655 insertions(+), 708 deletions(-) diff --git a/conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md b/conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md index 59d66a6a..c1f6e14e 100644 --- a/conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md +++ b/conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md @@ -1,708 +1,655 @@ -# WE BUILD - Conformance Specification: Issuance of Relying Party Access and Registration Certificates - -Version 0.3 -Date: 15 June 2026 -Author: Jilles van Oossanen - -## Table of Contents - -- [1. Introduction](#1-introduction) -- [2. Scope](#2-scope) -- [3. Normative Language and Terminology](#3-normative-language-and-terminology) -- [4. Roles and Components](#4-roles-and-components) -- [5. Protocol Overview](#5-protocol-overview) - - [5.1 ACME Resource Model](#51-acme-resource-model) - - [5.2 WRPAC Identifier Type](#52-wrpac-identifier-type) - - [5.3 Challenge Type: registrar-api-01](#53-challenge-type-registrar-api-01) - - [5.4 EBW-based Account Binding](#54-ebw-based-account-binding) -- [6. High-level Flows](#6-high-level-flows) - - [6.1 Directory Discovery](#61-directory-discovery) - - [6.2 Account Creation with EBW Authentication](#62-account-creation-with-ebw-authentication) - - [6.3 Order Creation](#63-order-creation) - - [6.4 Authorization and Challenge Validation](#64-authorization-and-challenge-validation) - - [6.5 Order Finalization](#65-order-finalization) - - [6.6 Certificate Download](#66-certificate-download) - - [6.7 Certificate Revocation](#67-certificate-revocation) -- [7. Normative Requirements](#7-normative-requirements) - - [7.1 Common Requirements](#71-common-requirements) - - [7.2 ACME Server (CA / RA)](#72-acme-server-ca--ra) - - [7.3 ACME Client (WRP / EBW)](#73-acme-client-wrp--ebw) - - [7.4 Certificate Profile](#74-certificate-profile) - - [7.5 Certificate Transparency](#75-certificate-transparency) - - [7.6 Revocation](#76-revocation) - - [7.7 Trusted List Integration](#77-trusted-list-integration) - - [7.8 WE BUILD RP Lists](#78-we-build-rp-lists) - - [7.9 IANA Considerations](#79-iana-considerations) -- [8. Interface Definitions](#8-interface-definitions) - - [8.1 ACME Directory](#81-acme-directory) - - [8.2 Account Management with EBW/EBWOID](#82-account-management-with-ebwebwoid) - - [8.3 Order Lifecycle](#83-order-lifecycle) - - [8.4 Authorization and Challenge](#84-authorization-and-challenge) - - [8.5 Finalize and Certificate](#85-finalize-and-certificate) - - [8.6 Revocation](#86-revocation) -- [9. Conformance](#9-conformance) -- [References](#references) - ---- - -# 1. Introduction - -This document defines the **WE BUILD Consortium Conformance Specification (CS)** for the issuance of Wallet-Relying Party Access Certificates (WRPACs) and, where applicable, Wallet-Relying Party Registration Certificates (WRPRCs) within the European Digital Identity Wallet ecosystem, using a protocol based on the Automatic Certificate Management Environment (ACME) as defined in RFC 8555 [1]. - -It profiles: - -* Commission Implementing Regulation (EU) 2025/848 [2], in particular Article 7, Annex I, Annex IV, and Annex V (the latter for optional WRPRC co-issuance) -* ETSI TS 119 411-8 v1.1.1 (2025-10) [3] — Access Certificate Policy for EUDI Wallet Relying Parties -* ETSI TS 119 475 v1.2.1 (2026-03) [4] — Relying party attributes supporting EUDI Wallet user's authorisation decisions, in particular Annex D, Use Case 1 (Integrated model) -* WE BUILD WP4 Architecture Blueprint [5] — RPAC/RPRC documentation and issuance process -* IETF RFC 8555 [1] — Automatic Certificate Management Environment (ACME) - -This specification positions the ACME protocol as a **technical implementation** of the integrated issuance model described in ETSI TS 119 475 v1.2.1 Annex D, Use Case 1 [4], as adopted by the WE BUILD Blueprint [5]. Section 9 provides an explicit mapping between the WE BUILD Blueprint issuance workflow and the ACME protocol operations. - -This specification focuses **only on direct issuance** of WRPACs to registered Wallet-Relying Parties. The ACME flow is extended with EBW-based authentication and EBWOID verification to align with the Blueprint's requirements for user authentication via European Business Wallets. This document is used to build the WE BUILD Interoperability Test Bed Plus (ITB+) [6]. - -> [!IMPORTANT] -> CS-RPAC_01: This specification is intended for **interoperability testing only**, not for production deployment. Its purpose is to validate the technical feasibility of ACME-based WRPAC issuance and to establish interoperability between independent implementations. Production deployments will require additional security hardening, policy alignment, and conformity assessment beyond the scope of this document. - -> [!NOTE] -> CS-RPAC_02: CIR (EU) 2025/848 [2] was adopted on 6 May 2025 and **applies from 24 December 2026**. Requirements in this specification that derive from CIR (EU) 2025/848 are therefore drafted in anticipation of that date of application. - -# 2. Scope - -This specification defines: - -* An ACME protocol profile (based on RFC 8555) for the automated issuance of X.509-based Wallet-Relying Party Access Certificates, aligned with the WE BUILD Blueprint issuance process -* A custom ACME identifier type (`wrp-id`) for WRP identifiers -* A custom ACME challenge type (`registrar-api-01`) for verifying WRP authorization against WE BUILD RP Lists -* An EBW-based account binding mechanism for user authentication via European Business Wallets -* Support for multi-instance issuance, enabling a single WRP to obtain separate WRPACs for multiple Relying Party Instances per the EUDI Wallet ARF v2.8 [15] -* Requirements for: - * ACME Servers (TSP Certificate Authorities and Registration Authorities) - * ACME Clients (Wallet-Relying Parties using their EBW) -* Protocol flows for: - * Direct WRPAC issuance to an authorized WRP (with optional co-issuance of WRPRC) - * Multi-instance issuance (one WRPAC per Relying Party Instance) - * Certificate revocation - -This specification does **not** cover: - -* Intermediary or multi-party issuance -* Standalone WRPRC issuance (covered in a separate CS) -* Production deployment requirements (conformity assessment, CAB audits, national policy extensions) -* Proximity use cases for certificate presentation - -# 3. Normative Language and Terminology - -The keywords MUST, MUST NOT, REQUIRED, SHALL, SHOULD, SHOULD NOT, RECOMMENDED, MAY and OPTIONAL are to be interpreted as commonly used in technical specifications. - -The following terminology applies throughout this specification: - -* **WRPAC** — Wallet-Relying Party Access Certificate. This specification uses the term **WRPAC** (matching ETSI TS 119 475 v1.2.1 [4] and CIR (EU) 2025/848 [2]). The abbreviation **RPAC** as used in some WE BUILD Blueprint [5] and ARF [15] discussion documents is synonymous. -* **WRPRC** — Wallet-Relying Party Registration Certificate. The abbreviation **RPRC** used elsewhere is synonymous. -* **EBW** — European Business Wallet. -* **EUDIW** — European Digital Identity Wallet. -* **RP** — Relying Party; in this specification usually **WRP** (Wallet-Relying Party). -* **RA / CA / TSP** — Registration Authority, Certificate Authority, Trust Service Provider. -* **EAA** — Electronic Attestation of Attributes. -* **PID** — Person Identification Data. -* **EBWOID** — European Business Wallet Organisational Identification Data, as defined in the WE BUILD EBWOID Attestation Rulebook [20]. Carries the organisation's unique identifier (`id`) and official name (`name`). - -# 4. Roles and Components - -This specification uses the following roles, mapped to both the ACME protocol and the WE BUILD Blueprint: - -| ACME Role | Blueprint Role | Description | -|---|---|---| -| **ACME Client** | **User + EBW** | The RP representative using an European Business Wallet. The EBW plays two roles in this specification: (i) host or invocation environment for the ACME Client software, and (ii) user-authentication authority for EBW-based External Account Binding per RFC 8555 §7.3.4 [1]. | -| **ACME Server** | **RA + CA** | The combined Registration Authority and Certificate Authority. The RA function handles identity verification, EBWOID validation, and RP list checks. The CA function handles certificate generation and signing. These MAY be separate systems behind a single ACME endpoint. | -| — | **Mock Registrar (RA/TSP)** | In the WE BUILD pilot, participating TSPs acting as mock Registrars maintain the Lists of authorised RPs. This role is functionally equivalent to the Member State Registrar in the production eIDAS ecosystem. In production, this function is performed by the national Registrar of the Member State concerned per the eIDAS framework. | -| **Wallet Unit** | — | The EUDI/Business Wallet that verifies WRPACs during RP authentication. Not involved in issuance. | - -Detailed role descriptions: - -* **User (RP Representative):** A natural person operating on behalf of the Wallet-Relying Party via the organisation's EBW. Authenticates via the EBW, which presents the organisation's EBWOID attestation (an EAA). Authorisation to obtain a certificate for the WRP follows from control of the organisation's EBW; this profile does not require a separate Power of Attorney or representative attestation. -* **European Business Wallet (EBW):** The wallet application used to authenticate to the RA and to retrieve issued certificates. In the ACME flow, the EBW acts as the ACME Client or provides the authentication layer for the ACME Client. -* **Registration Authority (RA):** The TSP component that verifies the organisation's identity by validating the EBWOID, requests additional attributes for the RPRC, and checks the RP's presence in the authorised RP lists. Implemented as part of the ACME Server. -* **Certificate Authority (CA):** The TSP component that generates and signs WRPAC and WRPRC certificates. Implemented as the ACME Server's certificate issuance backend. -* **Mock Registrar:** In the WE BUILD pilot, participating TSPs acting as mock Registrars establish and maintain the Lists of authorized Relying Parties. These lists serve as the functional equivalent of the national register of wallet-relying parties per the Blueprint [5] MVP governance model. The ACME Server checks these lists during authorization. During ITB+ conformance testing, the WE BUILD RP List is served by the mock Registrar; in production, by the national Registrar of the Member State concerned. - -# 5. Protocol Overview - -The WE BUILD WRPAC issuance protocol adapts the ACME framework (RFC 8555) to the EUDI Wallet trust model, implementing the integrated issuance model described in ETSI TS 119 475 v1.2.1 Annex D, Use Case 1 [4] as adopted by the WE BUILD Blueprint [5]. - -The key adaptations are: - -* **Identifier type `wrp-id`**: replaces the `dns` identifier. The value is the WRP's unique identifier as it appears in the WE BUILD RP Lists. For the `wrp-id` identifier type, the only defined challenge is `registrar-api-01`; the standard ACME challenges `http-01` and `dns-01` do not apply to `wrp-id`. -* **Challenge type `registrar-api-01`**: the ACME Server verifies the WRP's presence in the WE BUILD RP Lists and confirms a token link between the ACME account and the RP entry. The RP List entry supports multiple concurrent challenge tokens to enable multi-instance issuance (see §5.2). -* **EBW-based External Account Binding (EAB)**: REQUIRED for account creation. The EBW presents the organisation's EBWOID (EAA). The EAB binds the ACME account to the verified EBW organisational identity (EBWOID). -* **RA-integrated ACME Server**: the ACME Server combines RA and CA functions. The RA function validates the EBWOID, the organisation's identity, and RP list membership. The CA function generates certificates. -* **Multi-instance issuance**: a single WRP (`wrp-id`) MAY obtain multiple WRPACs, one per Relying Party Instance. Each instance is identified by an optional `instanceId` in the order (see §5.2). -* **Co-issuance of WRPAC + WRPRC**: the ACME order MAY result in both a WRPAC and a WRPRC being issued together, as specified in the Blueprint and in CIR (EU) 2025/848 Annex V [2]. -* **Certificate profile**: the issued certificate is an X.509 v3 WRPAC conforming to CIR 2025/848 Annex IV and ETSI TS 119 411-8 v1.1.1 [3], with attribute content as specified in ETSI TS 119 475 v1.2.1 clause 5 [4]. -* **Certificate Transparency**: the ACME Server logs all issued certificates per RFC 9162 [7] (see §7.5 for deployment guidance). - -All ACME messages are JSON payloads signed via JWS (RFC 7515 [8]), using JWK Thumbprints as specified in RFC 7638 [17], transported over HTTPS. - -> [!NOTE] -> CS-RPAC_03: The choice of ACME provides a proven, standardised, and automatable protocol for certificate lifecycle management. It implements the integrated issuance workflow described in ETSI TS 119 475 v1.2.1 Annex D, Use Case 1 (see Section 9) while enabling interoperability testing with off-the-shelf ACME tooling. The RA and CA functions defined in the Blueprint are encapsulated within the ACME Server. - -## 5.1 ACME Resource Model - -This profile follows the standard ACME resource model defined in RFC 8555 §7.1 [1]: - -``` -directory - | - +--> newNonce - | - +-----+-----+-----+-----+--------+ - | | | | | | - V V V V V V -newAccount newOrder revokeCert keyChange - | | - V V - account order ---+--> finalize (URL) - +--> certificate (URL) - +--> registrationCertificate (URL) [WE BUILD extension] - | - V - authorization - | - V - challenge -``` - -All standard ACME resource types and state transitions per RFC 8555 apply, with the extensions defined in §5.2, §5.3, and §5.4. - -The `registrationCertificate` URL is a WE BUILD extension to the standard RFC 8555 order object. Consistent with the conventions used by RFC 8555 for the `finalize` and `certificate` URLs, `registrationCertificate` is a URL-valued top-level field of the order object and is dereferenced via an authenticated POST-as-GET request. Registration considerations are described in §7.9 (IANA Considerations). - -## 5.2 WRPAC Identifier Type - -**Type**: `wrp-id` - -**Value**: the WRP's unique identifier as it appears in the WE BUILD RP Lists. For interoperability testing, the identifier format is defined by the mock Registrar. In the production eIDAS ecosystem, this would use CIR 2025/848 Annex I identifiers (EUID per CIR (EU) 2021/1042, LEI per CIR (EU) 2022/1860, EORI, national business register numbers, or VAT identification numbers). - -**Example:** - -```json -{ - "type": "wrp-id", - "value": "NLKVK.12345678" -} -``` - -**Multi-instance issuance**: A single WRP identified by one `wrp-id` value MAY obtain multiple WRPACs, one per Relying Party Instance, in accordance with the Relying Party Instance model described in the EUDI Wallet ARF v2.8 [15]. Each order MAY include an optional `instanceId` string that uniquely identifies the specific Relying Party Instance within the WRP's deployment. When `instanceId` is provided: - -* The ACME Server MUST verify that the `instanceId` is distinct from all other currently valid WRPACs issued to the same `wrp-id`. -* The issued WRPAC MAY include the `instanceId` in a non-critical Subject Alternative Name extension or in the Subject's Common Name at the CA's discretion. -* The RP List entry for the `wrp-id` MUST support multiple concurrent `acme-challenge` tokens, keyed by `instanceId` (see §7.8). - -When `instanceId` is omitted, the ACME Server treats the order as targeting a single-instance deployment. If a valid WRPAC already exists for that `wrp-id` without an `instanceId`, the new order replaces the previous certificate; the ACME Server MUST initiate revocation of the superseded certificate upon successful issuance of the replacement (see §7.2 item 12). - -## 5.3 Challenge Type: registrar-api-01 - -**Type**: `registrar-api-01` - -**Mechanism:** - -1. The ACME Server generates a random `token` and returns it as part of the challenge object. -2. The ACME Client constructs a `key-authorization` per RFC 8555 §8.1 [1]: `token || '.' || base64url(Thumbprint(accountKey))`, where `Thumbprint` is the JWK Thumbprint defined in RFC 7638 [17]. -3. The ACME Client places the `key-authorization` as the `acme-challenge` attribute in its RP List entry. For multi-instance issuance, the value is placed under the key corresponding to the `instanceId` of the order (or `"default"` if no `instanceId` was specified). -4. The ACME Client signals readiness by POSTing to the challenge URL. -5. The ACME Server checks the RP List: - * WRP is present and authorized - * `acme-challenge` attribute (for the relevant `instanceId`) matches the expected `key-authorization` - * The EBWOID bound to the account corresponds to the WRP: `EBWOID.id` equals the order's `wrp-id` and `EBWOID.name` matches the entry's *WRP legal name* (§7.2 item 9) -6. Authorization transitions to `valid` on success. - -**Challenge object:** - -```json -{ - "type": "registrar-api-01", - "url": "https://acme.example/chall/abc123", - "status": "pending", - "token": "DGyRejbN4F7wAJ3gPMR8Kw0VxBWgKEn2T4u-bQ_6Oc" -} -``` - -> [!NOTE] -> CS-RPAC_04: In the WE BUILD pilot, the RP Lists maintained by participating TSPs acting as mock Registrars replace the national register. The `registrar-api-01` challenge verifies presence in these lists (Blueprint step 5). In production, this would query the Registrar's API per EC TS5 [9] and EC TS6 [16]. - -## 5.4 EBW-based Account Binding - -This profile extends ACME External Account Binding (RFC 8555 §7.3.4 [1]) with EBW authentication. This implements Blueprint steps 1-3. - -**Mechanism:** - -1. The EBW authenticates to the RA, presenting the organisation's EBWOID attestation (EAA). -2. The RA validates the EBWOID and verifies the organisation's identity. -3. The RA issues EAB credentials (Key ID + HMAC Key) bound to the verified organisational identity (EBWOID). -4. The ACME Client uses these EAB credentials during `newAccount`. - -**EAB in newAccount:** - -Per RFC 8555 §7.3.4 [1], the `externalAccountBinding` value is a flattened JWS whose payload is the ACME account key in JWK form, and whose protected header carries `alg` (a MAC algorithm), `kid` (the CA-issued EAB Key Identifier), and `url`. - -```json -{ - "termsOfServiceAgreed": true, - "contact": ["mailto:representative@rp.example"], - "externalAccountBinding": { - "protected": "", - "payload": "", - "signature": "" - } -} -``` - -> [!NOTE] -> CS-RPAC_05: **[MVP]** For initial interoperability testing, EBW authentication MAY be simulated. EAB credentials are pre-provisioned by the RA/TSP after out-of-band identity verification. **[MVP+]** Future iterations MUST implement the full EBW-based OID4VP authentication flow for EAB provisioning, including presentation of the organisation's EBWOID (EAA) via OID4VP. Implementations declaring MVP+ conformance MUST support the full OID4VP-based EBW authentication flow. - -# 6. High-level Flows - -## 6.1 Directory Discovery - -1. The ACME Client fetches the directory document via HTTPS GET. -2. The directory includes metadata: `externalAccountRequired: true`, `supportedIdentifierTypes: ["wrp-id"]`, `supportedChallengeTypes: ["registrar-api-01"]`. - -## 6.2 Account Creation with EBW Authentication - -**Pre-ACME phase (Blueprint steps 1-3):** - -1. The RP representative connects to the TSP's RA portal/service via the organisation's EBW. -2. **[MVP+]** The RA initiates an OID4VP request to the EBW, requesting the organisation's EBWOID (EAA). **[MVP]** This step MAY be performed out-of-band. -3. The EBW presents the EBWOID. -4. The RA validates the EBWOID and verifies the organisation's identity. -5. Optionally (Blueprint step 4): the RA requests additional attributes for WRPRC production. -6. The RA issues EAB credentials to the EBW. - -**ACME phase:** - -7. The ACME Client generates an account key pair. -8. The ACME Client sends `newAccount` with the EAB binding. -9. The ACME Server validates the EAB and creates the account. - -## 6.3 Order Creation - -1. The ACME Client sends `newOrder` with one `wrp-id` identifier and an optional `instanceId`. -2. The ACME Server creates the order in `pending` state and returns authorization URL(s) and `finalize` URL. -3. For multi-instance issuance: the ACME Server MUST verify that the `instanceId` (if provided) is not already in use by a currently valid WRPAC for the same `wrp-id`. - -## 6.4 Authorization and Challenge Validation - -*Blueprint step 5: RP list check.* - -1. The ACME Client fetches the authorization. -2. The ACME Client completes the `registrar-api-01` challenge: compute `key-authorization`, place in RP List (under the relevant `instanceId` key), signal readiness. -3. The ACME Server checks the RP List and validates. -4. Authorization transitions to `valid`. - -## 6.5 Order Finalization - -*Blueprint steps 6-9: order, issue, transmit, notify.* - -1. The ACME Client generates a certificate key pair and CSR. Per §7.3 item 4, the ACME Client MUST generate a distinct key pair for each Relying Party Instance. -2. The ACME Client sends the CSR to the `finalize` URL. -3. The ACME Server (RA) validates; the ACME Server (CA) generates WRPAC (and optionally WRPRC). -4. The CA submits to CT logs and obtains SCTs. -5. The order transitions to `valid` with a `certificate` URL (and optionally `registrationCertificate` URL). -6. The ACME Server MAY send out-of-band notification to the RP representative. - -## 6.6 Certificate Download - -*Blueprint steps 10-11: authenticate + retrieve.* - -1. The ACME Client sends POST-as-GET to the `certificate` URL (authenticated via account key bound to EBW). -2. The ACME Server returns the PEM certificate chain. -3. If WRPRC was co-issued, it is available at the `registrationCertificate` URL. - -## 6.7 Certificate Revocation - -1. Client-initiated: `revokeCert` request. -2. Server-initiated: upon RP removal from the RP List, or upon revocation of a specific Relying Party Instance. -3. For multi-instance deployments: revocation of one instance's WRPAC MUST NOT affect WRPACs issued to other instances of the same WRP. - -# 7. Normative Requirements - -## 7.1 Common Requirements - -Both ACME Client and ACME Server **MUST**: - -1. Implement the ACME protocol per RFC 8555 [1] with the extensions in §5. -2. Use HTTPS for all communication. -3. Use JWS (RFC 7515 [8]) for all request payloads, with JWK Thumbprints computed per RFC 7638 [17] where required by ACME. -4. Implement nonce-based replay protection. - -## 7.2 ACME Server (CA / RA) - -The ACME Server **MUST**: - -1. Publish an ACME directory at a well-known URL (see §7.9 for IANA considerations). -2. Require EAB for all accounts (RFC 8555 §7.3.4 [1]). -3. Support `wrp-id` identifiers and `registrar-api-01` challenges. -4. Verify WRP presence in the WE BUILD RP Lists during challenge validation (Blueprint step 5). -5. Reject authorization if the WRP is not in the list, or if the EBWOID bound to the account does not correspond to the requested `wrp-id` (see item 9). -6. Issue WRPACs exclusively to authorized WRPs. -7. Support the order state transitions defined in RFC 8555 §7.1.6 "Status Changes" [1]. -8. Support `revokeCert`. -9. Implement the RA function: EBWOID validation (Blueprint steps 1-3) and RP List check (step 5). As part of EBWOID validation, the ACME Server MUST verify that the organisation identified by the validated EBWOID corresponds to the order's `wrp-id`: specifically that `EBWOID.id` equals the `wrp-id` value and `EBWOID.name` matches the *WRP legal name* of the corresponding RP List entry (§7.8). The ACME Server MUST reject the order or authorization if they do not correspond. -10. Support multi-instance issuance: accept an optional `instanceId` in orders, verify its uniqueness among active WRPACs for the same `wrp-id`, and issue separate WRPACs per instance (§5.2). -11. Enforce that each Relying Party Instance receives a distinct WRPAC bound to a distinct key pair (see §7.3 item 4). -12. Upon successful replacement of a single-instance WRPAC (new order without `instanceId` replacing an existing single-instance WRPAC), MUST initiate revocation of the superseded certificate (see §5.2). - -The ACME Server **SHOULD**: - -1. Support co-issuance of WRPRC alongside WRPAC in a single order (WRPRC profile per CIR (EU) 2025/848 Annex V [2]). -2. Send out-of-band notification when certificates are ready (Blueprint step 9). -3. Support `keyChange`. - -## 7.3 ACME Client (WRP / EBW) - -The ACME Client **MUST**: - -1. Perform directory discovery. -2. Support EAB using credentials obtained through EBW authentication. -3. Support `wrp-id` identifiers and `registrar-api-01` challenges. -4. Generate a distinct key pair per Relying Party Instance and construct valid CSRs per §7.4. For single-instance issuance, a single key pair applies; for multi-instance issuance, a distinct key pair MUST be used for each `instanceId`. This is the single normative key-separation rule applied by both the Client and the Server (see §7.2 item 11). -5. Validate the returned certificate chain. -6. Support `revokeCert`. - -The ACME Client **SHOULD**: - -1. Integrate with the EBW for authentication during EAB provisioning (REQUIRED for MVP+ conformance). -2. Include an `instanceId` in orders for multi-instance deployments. -3. Clean up `acme-challenge` entries from the RP List after successful issuance or upon authorization failure. - -## 7.4 Certificate Profile - -Issued WRPACs **MUST**: - -1. Be X.509 v3 public-key certificates conforming to RFC 5280 [10]. -2. Comply with ETSI TS 119 411-8 v1.1.1 [3] (Access Certificate Policy for EUDI Wallet Relying Parties), which in turn applies the NCP policy as specified in ETSI EN 319 411-1 v1.5.1 [12] and the WRPAC-specific policy identifiers `NCP-n-eudiwrp` (natural person) and `NCP-l-eudiwrp` (legal person). -3. Contain the WRP's legal name in the Subject field (CIR 2025/848 Annex I point 1 [2]). -4. Contain a user-friendly name where applicable (Annex I point 2). -5. Contain at least one unique WRP identifier (Annex I point 3), selected from EUID, LEI, EORI, national business register number, or VAT identification number. -6. Include a certificate policy OID (Annex IV). -7. Include certification path information (URI). -8. Include a SAN extension (DNS name or URI). -9. Include registration information per Annex I points 1, 2, and 8. -10. Express attribute content (e.g. `organizationIdentifier`, semantic identifiers) using the Subject DN and certificate-extension attributes defined in ETSI EN 319 412-1 [18] and ETSI TS 119 475 v1.2.1 clause 5 [4]. -11. Conform to ETSI TS 119 475 v1.2.1 [4]. -12. Use RSA (min. 3072 bits), ECDSA (P-256 or P-384), SHA-256 or stronger. -13. Have a validity period of **one year** from the date of issuance. Shorter validity periods are NOT used in WE BUILD interoperability testing. - -> [!NOTE] -> CS-RPAC_06: For interoperability testing, implementations SHOULD support both RSA and ECDSA. NCP as defined in ETSI EN 319 411-1 v1.5.1 [12] is the baseline policy level, with the WRPAC-specific extensions of TS 119 411-8 v1.1.1 [3] applied on top. The one-year validity period applies uniformly in the WE BUILD ITB+ test environment. - -> [!NOTE] -> CS-RPAC_07: WRPACs are X.509 public-key certificates; they are not X.509 Attribute Certificates. Attribute content in WRPACs is expressed through public-key certificate extensions and ETSI-defined Subject DN attributes (ETSI EN 319 412 series [18], ETSI TS 119 475 v1.2.1 clause 5 [4]). The Attribute Certificate profile defined in RFC 5755 is therefore not applicable to WRPACs and is not used in this specification. - -## 7.5 Certificate Transparency - -The ACME Server **MUST** log all WRPACs in CT logs and embed SCTs. - -> [!NOTE] -> CS-RPAC_08: RFC 9162 [7] ("Certificate Transparency Version 2.0", Experimental, December 2021) is the current IETF specification for CT and obsoletes RFC 6962. However, virtually all production CT log operators and browser CT policies currently implement RFC 6962. For interoperability testing in WE BUILD ITB+, implementations MAY use test CT logs and MAY rely on RFC 6962 where production tooling is not yet available for RFC 9162. Implementations MUST document which CT specification they implement. - -## 7.6 Revocation - -The ACME Server **MUST** support client-initiated (`revokeCert`) and server-initiated revocation (RP List removal or instance-specific revocation), and publish status via OCSP and/or CRL. - -For multi-instance deployments, revocation of one instance's WRPAC MUST be scoped to that instance and MUST NOT affect WRPACs issued to other instances of the same WRP. - -> [!NOTE] -> CS-RPAC_09: For interoperability testing, RP List removal MAY be simulated. - -## 7.7 Trusted List Integration - -1. The test environment MUST include a simulated Trusted List containing the trust anchor certificate(s) of the CA(s) participating in the WE BUILD pilot. Individual WRPAC end-entity certificates are NOT listed in the Trusted List; trust is established through the CA trust anchor chain. -2. Wallet Units MUST verify WRPACs by building and validating the certificate chain up to a CA trust anchor present in the Trusted List. - -## 7.8 WE BUILD RP Lists - -In the WE BUILD pilot, participating TSPs acting as mock Registrars establish and maintain the Lists of authorized Relying Parties, in accordance with the Blueprint [5] MVP governance model. This is the functional equivalent of the national register of wallet-relying parties. - -1. The ACME Server MUST check these lists as a precondition for issuance. -2. The RP List MUST support an `acme-challenge` metadata structure that accommodates multiple concurrent challenge tokens keyed by `instanceId`, to support multi-instance issuance. -3. In the absence of multi-instance ordering, the `acme-challenge` attribute MAY be a single string value (equivalent to `instanceId = "default"`). - -Each RP List entry MUST contain at minimum: - -* WRP unique identifier -* WRP legal name -* Authorization status (active / revoked) -* Metadata fields including `acme-challenge`, structured as follows: - -```json -{ - "acme-challenge": { - "default": "", - "instance-A": "", - "instance-B": "" - } -} -``` - -For single-instance deployments, the following shorthand form is also valid: - -```json -{ - "acme-challenge": "" -} -``` - -The ACME Server MUST accept both forms and normalize them internally. - -## 7.9 IANA Considerations - -This specification defines extensions to the ACME protocol that fall within IANA registries established by RFC 8555 and its successors, and introduces a custom URI suffix. For interoperability-testing purposes within WE BUILD ITB+, registration is not a prerequisite; for any future production use, the following registrations would be required. - -**ACME Identifier Types registry** (established by RFC 8555): the identifier type `wrp-id` (§5.2) would need to be registered. - -**Well-Known URI registry** (RFC 8615 [19]): the URI suffix `acme-eudi-wrpac` (§8.1) would need to be registered if the `/.well-known/` prefix is used. Alternatively, since RFC 8555 does not mandate the use of `/.well-known/` for the ACME directory, a conventional (non-`.well-known/`) path MAY be used, as shown in §8.1. Implementations intending to place the ACME directory under `/.well-known/acme-eudi-wrpac/` MUST complete the RFC 8615 registration process. - -**ACME directory metadata extensions**: the metadata fields `rprcCoIssuanceSupported` and `multiInstanceIssuanceSupported` (§8.1) are WE BUILD-specific extensions. The IANA "ACME Directory Metadata Fields" registry (established by RFC 8555) would need to be updated for any production deployment. - -**ACME order object extension**: the `registrationCertificate` URL field (§5.1, §8.5) is a WE BUILD-specific extension to the standard ACME order object. For production deployment, a URN-style field name (for example, `urn:webuild:acme:wrprc`) MAY be preferred to avoid conflicts with future IETF extensions. - -# 8. Interface Definitions - -## 8.1 ACME Directory - -* **Method**: `GET` -* **URL**: `{base}/acme-eudi-wrpac/directory` (see §7.9 for URI considerations) - -```json -{ - "newNonce": "https://acme.example/new-nonce", - "newAccount": "https://acme.example/new-acct", - "newOrder": "https://acme.example/new-order", - "revokeCert": "https://acme.example/revoke-cert", - "keyChange": "https://acme.example/key-change", - "meta": { - "termsOfService": "https://acme.example/terms", - "externalAccountRequired": true, - "supportedIdentifierTypes": ["wrp-id"], - "supportedChallengeTypes": ["registrar-api-01"], - "rprcCoIssuanceSupported": true, - "multiInstanceIssuanceSupported": true - } -} -``` - -> [!NOTE] -> CS-RPAC_10: `rprcCoIssuanceSupported` and `multiInstanceIssuanceSupported` are WE BUILD extensions to the ACME directory metadata. The former indicates the server can co-issue WRPAC and WRPRC; the latter indicates support for per-instance issuance per the ARF v2.8 Relying Party Instance model [15]. Registration considerations for these extensions are described in §7.9. - -## 8.2 Account Management with EBW/EBWOID - -* **Method**: `POST` (JWS-signed) - -**Pre-condition**: EBW authentication + EBWOID validation completed; EAB credentials received. - -```json -{ - "termsOfServiceAgreed": true, - "contact": ["mailto:representative@rp.example"], - "externalAccountBinding": { - "protected": "", - "payload": "", - "signature": "" - } -} -``` - -## 8.3 Order Lifecycle - -**Create order (single instance):** - -```json -{ - "identifiers": [ - { "type": "wrp-id", "value": "NLKVK.12345678" } - ] -} -``` - -**Create order (multi-instance, with instanceId):** - -```json -{ - "identifiers": [ - { "type": "wrp-id", "value": "NLKVK.12345678" } - ], - "instanceId": "production-eu-west-1" -} -``` - -The `instanceId` value is an opaque string chosen by the ACME Client. It MUST be unique among all currently valid WRPACs issued to the same `wrp-id`. Recommended format: a short alphanumeric label identifying the deployment context (e.g., environment, region, or service name). - -**Order states** (per RFC 8555 §7.1.6 "Status Changes" [1]): `pending` → `ready` → `processing` → `valid` (or `invalid`). - -## 8.4 Authorization and Challenge - -**Authorization response:** - -```json -{ - "status": "pending", - "identifier": { "type": "wrp-id", "value": "NLKVK.12345678" }, - "challenges": [ - { - "type": "registrar-api-01", - "url": "https://acme.example/chall/chall789", - "status": "pending", - "token": "DGyRejbN4F7wAJ3gPMR8Kw0VxBWgKEn2T4u-bQ_6Oc" - } - ] -} -``` - -The ACME Client places the computed `key-authorization` in the RP List entry's `acme-challenge` structure under the key matching the `instanceId` of the order (or `"default"` if no `instanceId` was specified), before signalling readiness. - -## 8.5 Finalize and Certificate - -**Finalize:** - -```json -{ "csr": "" } -``` - -**Order with co-issued WRPRC (WE BUILD extension):** - -```json -{ - "status": "valid", - "identifiers": [ - { "type": "wrp-id", "value": "NLKVK.12345678" } - ], - "instanceId": "production-eu-west-1", - "certificate": "https://acme.example/cert/cert012", - "registrationCertificate": "https://acme.example/rprc/rprc012", - "finalize": "https://acme.example/order/ord456/finalize", - "authorizations": ["https://acme.example/authz/xyz789"] -} -``` - -The `registrationCertificate` field is a WE BUILD extension to the standard RFC 8555 order object. Like the standard `finalize` and `certificate` fields, it is a URL-valued top-level field of the order object and is dereferenced via an authenticated POST-as-GET. The `instanceId` field in the order response echoes the value from the order request. - -**Certificate download**: `application/pem-certificate-chain` per RFC 8555 §7.4.2 [1] (end-entity first). - -## 8.6 Revocation - -```json -{ "certificate": "", "reason": 0 } -``` - -For multi-instance deployments, revocation targets the specific certificate identified by the DER-encoded value. Other WRPACs issued to the same `wrp-id` are unaffected. - -# 9. Conformance - -An implementation **conforms as an ACME Server (TSP: RA + CA)** if it: - -1. Implements ACME per RFC 8555 [1] with the extensions in §5. -2. Publishes an ACME directory per §8.1. -3. Requires EAB with EBW/EBWOID verification (§5.4). -4. Supports `wrp-id` identifiers and `registrar-api-01` challenges. -5. Verifies WRP presence in WE BUILD RP Lists (§7.8). -6. Issues certificates per §7.4, including compliance with ETSI TS 119 411-8 v1.1.1 [3] and the attribute mapping in ETSI TS 119 475 v1.2.1 clause 5 [4]. -7. Logs to CT logs (§7.5). -8. Supports revocation (§7.6). -9. Implements RA function: EBWOID validation, including the EBWOID-to-`wrp-id` match (`EBWOID.id` = `wrp-id`, `EBWOID.name` = *WRP legal name*) per §7.2 item 9, plus RP List checks (§7.2). -10. Supports multi-instance issuance per §5.2, §7.2, and §8.3. -11. Implements interfaces per §8. - -An implementation **conforms as an ACME Client (WRP / EBW)** if it: - -1. Implements ACME per RFC 8555 [1] with the extensions in §5. -2. Supports EBW-based EAB provisioning (§5.4). -3. Creates accounts with EAB, orders with `wrp-id` (and optional `instanceId`), and completes `registrar-api-01` challenges. -4. Submits valid CSRs with distinct key pairs per instance, and validates certificate chains. -5. Supports revocation. - -An implementation **conforms as a test environment** if it: - -1. Provides WE BUILD RP Lists with `acme-challenge` metadata supporting both single-value and multi-instance (`instanceId`-keyed) formats. -2. Provides a simulated Trusted List containing CA trust anchor certificate(s). -3. Provides a CT log (or test equivalent). -4. Supports EAB provisioning (or simulation per MVP scope). -5. Supports automated full-lifecycle testing, including multi-instance issuance scenarios. - -An **MVP+ conformant** implementation additionally: - -1. Implements the full OID4VP-based EBW authentication flow for EAB provisioning (§5.4, §6.2). -2. Accepts the organisation's EBWOID (EAA) as presented via the EBW during the pre-ACME authentication phase. - -Profiles for specific WE BUILD credential types MUST NOT relax these requirements. - -**Mapping of the WE BUILD Blueprint issuance workflow (derived from ETSI TS 119 475 v1.2.1 Annex D, Use Case 1: Integrated model [4]) to ACME protocol operations:** - -| Blueprint Step | Description | ACME Operation | Section | -|---|---|---|---| -| **1** | User authenticates to RA using EBW | EBW authentication → EAB provisioning | §5.4, §8.2 | -| **2** | RA requests credentials | OID4VP request from RA to EBW [MVP+] | §5.4 | -| **3** | User supplies EAA (EBWOID) | EBW presents EBWOID to RA | §5.4 | -| **4** | RA requests additional WRPRC attributes | Collected during EAB provisioning or as order metadata | §8.3 | -| **5** | RA checks RP in authorized RP lists | `registrar-api-01` challenge validation | §6.4, §8.4 | -| **6** | RA orders issuance of both certificates | ACME `finalize` (CSR submission) | §8.5 | -| **7** | CA issues WRPAC and WRPRC | ACME Server generates certificates, order → `valid` | §8.5 | -| **8** | CA transmits certificates to RA | Internal (ACME Server encapsulates RA+CA) | — | -| **9** | RA notifies user (e.g. by email) | Order polling: status → `valid`. Optional out-of-band notification. | §8.5 | -| **10** | User authenticates via EBW | Certificate download authenticated via JWS-signed POST-as-GET (account key bound to EBW via EAB) | §8.6 | -| **11** | User retrieves WRPAC and WRPRC | ACME certificate download from `certificate` URL | §8.6 | - -> [!NOTE] -> CS-RPAC_11: The table above maps the eleven-step workflow used by the WE BUILD Blueprint [5] — which is derived from the integrated issuance model described in ETSI TS 119 475 v1.2.1 Annex D, Use Case 1 [4] — to the ACME protocol operations defined by this specification. ETSI TS 119 475 Annex D is informative and presents four use cases (D.1 Integrated model, D.2 Registrar-initiated, D.3 RP-initiated, D.4 Provider-assisted); the eleven discrete steps enumerated above are a WE BUILD refinement of the D.1 flow and are not themselves a normative ETSI enumeration. - -**Key design decisions:** - -* **Steps 1-3** (EBW auth + EBWOID) are a **pre-ACME phase** producing EAB credentials. This cleanly separates organisational authentication from the machine protocol. Authorisation derives from control of the organisation's EBW and the EBWOID it presents; the EBWOID's `id`/`name` are matched against the order's `wrp-id` (§7.2 item 9). Full OID4VP-based EBW authentication is required for MVP+ conformance. -* **Step 4** (WRPRC attributes) is collected either during EAB provisioning or as ACME order metadata. -* **Step 5** (RP list check) maps directly to the `registrar-api-01` challenge. -* **Steps 6-8** (order, issue, transmit) map to ACME `finalize` → `processing` → `valid`. -* **Steps 9-11** (notify, auth, retrieve) map to order polling and certificate download, with optional email notification. - -> [!NOTE] -> CS-RPAC_12: The Blueprint specifies RA and CA as separate actors. In this ACME profile they are encapsulated in a single endpoint. Implementations MAY separate them internally. This is consistent with real-world ACME CAs where the RA front-end and CA signing backend are separate systems behind one protocol interface. - -# References - -[1] IETF (2019) RFC 8555 — Automatic Certificate Management Environment (ACME). Standards Track, March 2019. https://www.rfc-editor.org/rfc/rfc8555 - -[2] European Commission (2025) Commission Implementing Regulation (EU) 2025/848 of 6 May 2025 laying down rules for the application of Regulation (EU) No 910/2014 of the European Parliament and of the Council as regards the registration of wallet-relying parties. OJ L, 2025/848, 7.5.2025. Applies from 24 December 2026. http://data.europa.eu/eli/reg_impl/2025/848/oj - -[3] ETSI (2025) ETSI TS 119 411-8 v1.1.1 (2025-10) — Electronic Signatures and Trust Infrastructures (ESI); Policy and security requirements for Trust Service Providers issuing certificates; Part 8: Access Certificate Policy for EUDI Wallet Relying Parties. - -[4] ETSI (2026) ETSI TS 119 475 v1.2.1 (2026-03) — Electronic Signatures and Trust Infrastructures (ESI); Relying party attributes supporting EUDI Wallet user's authorisation decisions. In particular Annex D, Use Case 1 (Integrated model). - -[5] WE BUILD (2026) WP4 Architecture Blueprint — RPAC/RPRC Documentation. https://webuild-consortium.github.io/wp4-architecture/blueprint/blueprint.html - -[6] WE BUILD (2025) ITB+ Reference Specification. https://github.com/webuild-consortium/wp4-interop-test-bed/ - -[7] IETF (2021) RFC 9162 — Certificate Transparency Version 2.0. Experimental, December 2021. Obsoletes RFC 6962. https://www.rfc-editor.org/rfc/rfc9162 - -[8] IETF (2015) RFC 7515 — JSON Web Signature (JWS). Standards Track, May 2015. https://www.rfc-editor.org/rfc/rfc7515 - -[9] European Commission (2025/2026) EC TS5 — Common formats and API for RP registration information. Draft, eu-digital-identity-wallet/eudi-doc-standards-and-technical-specifications repository. - -[10] IETF (2008) RFC 5280 — Internet X.509 Public Key Infrastructure Certificate and Certificate Revocation List (CRL) Profile. Standards Track, May 2008. https://www.rfc-editor.org/rfc/rfc5280 - -[11] [Reserved] - -[12] ETSI (2025) ETSI EN 319 411-1 v1.5.1 (2025-04) — Electronic Signatures and Trust Infrastructures (ESI); Policy and security requirements for Trust Service Providers issuing certificates; Part 1: General requirements. - -[13] IETF (2003) RFC 3647 — Internet X.509 Public Key Infrastructure Certificate Policy and Certification Practices Framework. Informational, November 2003. https://www.rfc-editor.org/rfc/rfc3647 - -[14] Regulation (EU) No 910/2014, as amended by Regulation (EU) 2024/1183 of the European Parliament and of the Council of 11 April 2024 establishing the European Digital Identity Framework. - -[15] EUDI Wallet Architecture and Reference Framework v2.8.0. https://eu-digital-identity-wallet.github.io/eudi-doc-architecture-and-reference-framework/ - -[16] European Commission (2025/2026) EC TS6 — Common set of RP registration information. Draft, eu-digital-identity-wallet/eudi-doc-standards-and-technical-specifications repository. - -[17] IETF (2015) RFC 7638 — JSON Web Key (JWK) Thumbprint. Standards Track, September 2015. https://www.rfc-editor.org/rfc/rfc7638 - -[18] ETSI EN 319 412-1 — Electronic Signatures and Trust Infrastructures (ESI); Certificate Profiles; Part 1: Overview and common data structures. - -[19] IETF (2019) RFC 8615 — Well-Known Uniform Resource Identifiers (URIs). Standards Track, May 2019. https://www.rfc-editor.org/rfc/rfc8615 - -[20] WE BUILD Attestation Rulebooks Catalog — EBWOID Attestation Rulebook (rb-ebwoid). https://github.com/webuild-consortium/webuild-attestation-rulebooks-catalog/tree/main/rulebooks/rb-ebwoid \ No newline at end of file +# WE BUILD - Conformance Specification: Issuance of Relying Party Access and Registration Certificates + +Version 1.0 +Date: 31 juillet 2026 +Original Author: Jilles Van Oossanen +Modified Proposal by : François CHASSERY + +## Table of Contents + +- [1. Introduction](#1-introduction) +- [2. Scope](#2-scope) +- [3. Normative Language and Terminology](#3-normative-language-and-terminology) +- [4. Roles and Components](#4-roles-and-components) +- [5. Protocol Overview](#5-protocol-overview) + - [5.1 ACME Resource Model](#51-acme-resource-model) + - [5.2 WRPAC Identifier Type](#52-wrpac-identifier-type) + - [5.3 EBW-based Account Binding](#53-ebw-based-account-binding) +- [6. High-level Flows](#6-high-level-flows) + - [6.1 Directory Discovery](#61-directory-discovery) + - [6.2 Account Creation with EBW Authentication](#62-account-creation-with-ebw-authentication) + - [6.3 Order Creation](#63-order-creation) + - [6.4 Order Finalization](#64-order-finalization) + - [6.5 Certificate Download](#65-certificate-download) + - [6.6 Certificate Revocation](#66-certificate-revocation) +- [7. Normative Requirements](#7-normative-requirements) + - [7.1 Common Requirements](#71-common-requirements) + - [7.2 ACME Server (CA / RA)](#72-acme-server-ca--ra) + - [7.3 ACME Client (WRP / EBW)](#73-acme-client-wrp--ebw) + - [7.4 Certificate Profile](#74-certificate-profile) + - [7.5 Certificate Transparency](#75-certificate-transparency) + - [7.6 Revocation](#76-revocation) + - [7.7 Trusted List Integration](#77-trusted-list-integration) + - [7.8 WE BUILD RP Lists](#78-we-build-rp-lists) + - [7.9 IANA Considerations](#79-iana-considerations) +- [8. Interface Definitions](#8-interface-definitions) + - [8.1 ACME Directory](#81-acme-directory) + - [8.2 Account Management with EBW/EBWOID](#82-account-management-with-ebwebwoid) + - [8.3 Order Lifecycle](#83-order-lifecycle) + - [8.4 Finalize and Certificate](#85-finalize-and-certificate) + - [8.5 Revocation](#86-revocation) +- [9. Conformance](#9-conformance) +- [References](#references) + +--- + +# 1. Introduction + +This document defines the **WE BUILD Consortium Conformance Specification (CS)** for the issuance of Wallet-Relying Party Access Certificates (WRPACs) and, where applicable, Wallet-Relying Party Registration Certificates (WRPRCs) within the European Digital Identity Wallet ecosystem, using a protocol based on the Automatic Certificate Management Environment (ACME) as defined in RFC 8555 [1]. + +It profiles: + +* Commission Implementing Regulation (EU) 2025/848 [2], in particular Article 7, Annex I, Annex IV, and Annex V (the latter for optional WRPRC co-issuance) +* ETSI TS 119 411-8 v1.1.1 (2025-10) [3] — Access Certificate Policy for EUDI Wallet Relying Parties +* ETSI TS 119 475 v1.2.1 (2026-03) [4] — Relying party attributes supporting EUDI Wallet user's authorisation decisions, in particular Annex D, Use Case 1 (Integrated model) +* WE BUILD WP4 Architecture Blueprint [5] — RPAC/RPRC documentation and issuance process +* IETF RFC 8555 [1] — Automatic Certificate Management Environment (ACME) + +This specification uses the ACME protocol as a **technical implementation** for automation of the integrated issuance model described in ETSI TS 119 475 v1.2.1 Annex D, Use Case 1 [4], as adopted by the WE BUILD Blueprint [5]. Section 9 provides an explicit mapping between the WE BUILD Blueprint issuance workflow and the ACME protocol operations. + +This specification focuses **only on direct issuance** of WRPACs to registered Wallet-Relying Parties. The ACME flow is preceded with EBW-based authentication and EBWOID verification to align with the Blueprint's requirements for user authentication via European Business Wallets. This document is used to build the WE BUILD Interoperability Test Bed Plus (ITB+) [6]. + +> [!IMPORTANT] +> CS-RPAC_01: This specification is intended for **interoperability testing only**, not for production deployment. Its purpose is to validate the technical feasibility of ACME-based WRPAC issuance and to establish interoperability between independent implementations. Production deployments will require additional security hardening, policy alignment, and conformity assessment beyond the scope of this document. + +> [!NOTE] +> CS-RPAC_02: CIR (EU) 2025/848 [2] was adopted on 6 May 2025 and **applies from 24 December 2026**. Requirements in this specification that derive from CIR (EU) 2025/848 are therefore drafted in anticipation of that date of application. + +# 2. Scope + +This specification defines: + +* An ACME protocol profile (based on RFC 8555) for the automated issuance of X.509-based Wallet-Relying Party Access Certificates, aligned with the WE BUILD Blueprint issuance process +* Support for multi-instance issuance, enabling a single WRP to obtain separate WRPACs for multiple Relying Party Instances per the EUDI Wallet ARF v2.8 [15] +* Requirements for: + * ACME Servers (TSP Certificate Authorities and Registration Authorities) + * ACME Clients (Wallet-Relying Parties) +* Protocol flows for: + * Optional WRPRC issuance after registration of an authorized WRP + * Direct WRPAC issuance to an authorized WRP + * Multi-instance issuance (one WRPAC per Relying Party Instance) + * Certificate revocation + +This specification does **not** cover: + +* Intermediary or multi-party issuance +* Standalone WRPRC issuance (covered in a separate CS) +* Production deployment requirements (conformity assessment, CAB audits, national policy extensions) +* Proximity use cases for certificate presentation + +# 3. Normative Language and Terminology + +The keywords MUST, MUST NOT, REQUIRED, SHALL, SHOULD, SHOULD NOT, RECOMMENDED, MAY and OPTIONAL are to be interpreted as commonly used in technical specifications. + +The following terminology applies throughout this specification: + +* **WRPAC** — Wallet-Relying Party Access Certificate. This specification uses the term **WRPAC** (matching ETSI TS 119 475 v1.2.1 [4] and CIR (EU) 2025/848 [2]). The abbreviation **RPAC** as used in some WE BUILD Blueprint [5] and ARF [15] discussion documents is synonymous. +* **WRPRC** — Wallet-Relying Party Registration Certificate. The abbreviation **RPRC** used elsewhere is synonymous. +* **EBW** — European Business Wallet. +* **EUDIW** — European Digital Identity Wallet. +* **RP** — Relying Party; in this specification usually **WRP** (Wallet-Relying Party). +* **RA / CA / TSP** — Registration Authority, Certificate Authority, Trust Service Provider. +* **CP** — Certification Policy for **RPAC** +* **EAA** — Electronic Attestation of Attributes. +* **EBWOID** — European Business Wallet Organisational Identification Data, as defined in the WE BUILD EBWOID Attestation Rulebook [20]. Carries the organisation's unique identifier (`id`) and official name (`name`). +* **Authorised representative** or **RP representative**: Individual acting on behalf of the RP +* **RP’s BACKEND**: IS of RP +* **RP’s FRONTEND**: ACME client of RP +* **TRUST LIST**: Trusted registry of accredited Relying Parties + +# 4. Roles and Components + +This specification uses the following roles, mapped to both the ACME protocol and the WE BUILD Blueprint: + +| ACME Role | Blueprint Role | Description | +|---|---|---| +| **User + EBW** | The RP representative using an European Business Wallet. The EBW plays a role in this specification of a user-authentication authority for EBW-based External Account Binding per RFC 8555 §7.3.4 [1]. | +| **ACME Server** | **RA + CA** | The combined Registration Authority and Certificate Authority. The RA function handles identity verification, EBWOID validation, and RP list checks. The CA function handles certificate generation and signing. These MAY be separate systems behind a single ACME endpoint. | +|**ACME CLIENT** **RP’s FRONTEND**| — | host or invocation environment for the ACME Client software | +| — | **Mock Registrar (RA/TSP)** | In the WE BUILD pilot, participating TSPs acting as mock Registrars maintain the Lists of authorised RPs. This role is functionally equivalent to the Member State Registrar in the production eIDAS ecosystem. In production, this function is performed by the national Registrar of the Member State concerned per the eIDAS framework. | +| **Wallet Unit** | — | The EUDI/Business Wallet that verifies WRPACs during RP authentication. Not involved in issuance. | + +Detailed role descriptions: + +* **User (RP Representative or authorised representative):** A natural person operating on behalf of the Wallet-Relying Party via the organisation's EBW. Authenticates via the EBW, which presents the organisation's EBWOID attestation (an EAA). Authorisation to obtain a certificate for the WRP follows from control of the organisation's EBW; this profile does not require a separate Power of Attorney or representative attestation. +* **European Business Wallet (EBW):** The wallet application used to authenticate to the RA +* **Registration Authority (RA):** The TSP component that verifies the organisation's identity by validating the EBWOID, requests additional attributes for the RPRC, and checks the RP's presence in the authorised RP lists. +* **Certificate Authority (CA):** The TSP component that generates and signs WRPAC and WRPRC certificates. Implemented as the ACME Server's certificate issuance backend. +* **Mock Registrar:** In the WE BUILD pilot, participating TSPs acting as mock Registrars establish and maintain the Lists of authorized Relying Parties. These lists serve as the functional equivalent of the national register of wallet-relying parties per the Blueprint [5] MVP governance model. The RA checks these lists during authorization. During ITB+ conformance testing, the WE BUILD RP List is served by the mock Registrar; in production, by the national Registrar of the Member State concerned. + +# 5. Protocol Overview + +The WE BUILD WRPAC issuance protocol uses the standard ACME framework (RFC 8555) where FQDN challenge will be disabled, in order to implement the integrated issuance model described in ETSI TS 119 475 v1.2.1 Annex D, Use Case 1 [4] as adopted by the WE BUILD Blueprint [5]. + +The key adaptations are: + +* ** External Account Binding (EAB)**: The EAB binds the ACME account to the credentials supplied to RP’s EBW. +EBW is REQUIRED for EAB issuance: The EBW presents the organisation's EBWOID (EAA) and identifies the authorised representative. +Trust List verification SHALL occur before EAB issuance. +* **RA **: The RA function validates the EBWOID, the organisation's identity, and RP list membership, and delivers RP’s ACME credentials. +* **Multi-instance issuance**: a single WRP (`wrp-id`) MAY obtain multiple WRPACs, one per Relying Party Instance. Each instance is identified by an optional `instanceId` in the order (see §5.2). +* * **Certificate profile**: the issued certificate is an X.509 v3 WRPAC conforming to CIR 2025/848 Annex IV and ETSI TS 119 411-8 v1.1.1 [3], with attribute content as specified in ETSI TS 119 475 v1.2.1 clause 5 [4]. +* **Certificate Transparency**: the ACME Server logs all issued certificates per RFC 9162 [7] (see §7.5 for deployment guidance). +* **ACME Challenge**: this challenge is disabled and replaced by an internal control by CA that the DN of RPAC contains EBWOID without any change. + +All ACME messages are JSON payloads signed via JWS (RFC 7515 [8]), using JWK Thumbprints as specified in RFC 7638 [17], transported over HTTPS. + +> [!NOTE] +> CS-RPAC_03: The choice of ACME provides a proven, standardised, and automatable protocol for certificate lifecycle management. It implements the integrated issuance workflow described in ETSI TS 119 475 v1.2.1 Annex D, Use Case 1 (see Section 9) while enabling interoperability testing with off-the-shelf ACME tooling. The RA functions defined in the Blueprint are encapsulated within the ACME Server. +> CS-RPAC_04: The choice of keeping standard ACME protocol but adding wrp-identifier will avoid to use a specific EWB for RP, and leave opened the opportunity of a complete ID and mandate verification by RA in the future. +>CS-RPAC_05: In the context of WEBUILD, RA will presume that the Id of the RP’s representative is true without performing a true identity verification conform to NCP Policy. +>CS-RPAC_06: In the context of WEBUILD, RA will consider that the authorised representative has an implicit mandate for requesting an NCP certificate. + +## 5.1 ACME Resource Model + +This profile follows the standard ACME resource model defined in RFC 8555 §7.1 [1]: + +``` +directory + | + +--> newNonce + | + +-----+-----+-----+-----+--------+ + | | | | | | + V V V V V V +newAccount newOrder revokeCert keyChange + | | + V V + account order ---+--> finalize (URL) + +--> certificate (URL) + +``` + +This profile follows RFC 8555 with the extensions defined in §5.2, §5.3. except that ACME Authorization and Challenge resources are not used. Authorization is performed during EAB provisioning and account creation. + +## 5.2 WRPAC Identifier Type + +**Type**: `wrp-id` + +**Value**: the WRP's unique identifier as it appears in the WE BUILD RP Lists and in RP’s EBWOID. For interoperability testing, the identifier format is defined by the mock Registrar. In the production eIDAS ecosystem, this would use CIR 2025/848 Annex I identifiers (EUID per CIR (EU) 2021/1042, LEI per CIR (EU) 2022/1860, National Trade Register numbers, or VAT identification numbers). + +**Example:** + +```json +{ + "type": "wrp-id", + "value": "NLKVK.12345678" +} +``` + +**Multi-instance issuance**: A single WRP identified by one `wrp-id` value MAY obtain multiple WRPACs, one per Relying Party Instance, in accordance with the Relying Party Instance model described in the EUDI Wallet ARF v2.8 [15]. Each order MAY include an optional `instanceId` string that uniquely identifies the specific Relying Party Instance within the WRP's deployment. When `instanceId` is provided: + +* If multi issuance is advertised the ACME Server MUST verify that the `instanceId` is distinct from all other currently valid WRPACs issued to the same `wrp-id`. +* If multi issuance is advertised the issued WRPAC MUST include the `instanceId` as an Organisational Unit in the Subject's Common Name. + +## 5.3 EBW-based Account Binding + +ACME External Account Binding (RFC 8555 §7.3.4 [1]) will rely on EBW authentication completed by a control of presence of the RP in the Trust List. This implements Blueprint steps 1-3. + +**Mechanism:** + +1. The EBW authenticates to the RA, presenting the organisation's EBWOID attestation (EAA). +2. The RA validates the EBWOID and verifies the organisation's identity. +3. The RA registers the authorised representative as a subscriber within the meaning of ETSI EN 319 411-1 §5.4.2. For WE BUILD interoperability testing, mandate validation is presumed based on control of the organisation's EBW and is not independently verified. +4. The RA issues EAB credentials (Key ID + HMAC Key) together with a registration URL and supply them to RP’s EBW. +5. The ACME Client uses these EAB credentials during `newAccount`. + +**EAB in newAccount:** + +Per RFC 8555 §7.3.4 [1], the `externalAccountBinding` value is a flattened JWS whose payload is the ACME account key in JWK form, and whose protected header carries `alg` (a MAC algorithm), `kid` (the CA-issued EAB Key Identifier), and `url`. + +```json +{ + "termsOfServiceAgreed": true, + "contact": ["mailto:representative@rp.example"], + "externalAccountBinding": { + "protected": "", + "payload": "", + "signature": "" + } +} +``` + +> [!NOTE] +> CS-RPAC_07: **[MVP]** For initial interoperability testing, EBW authentication MAY be simulated. EAB credentials are pre-provisioned by the RA/TSP after out-of-band identity verification. **[MVP+]** Future iterations MUST implement the full EBW-based OID4VP authentication flow for EAB provisioning, including presentation of the organisation's EBWOID (EAA) via OID4VP. Implementations declaring MVP+ conformance MUST support the full OID4VP-based EBW authentication flow. + +# 6. High-level Flows + +## 6.1 Directory Discovery + +1. The ACME Client fetches the directory document via HTTPS GET. +2. The directory includes metadata: `externalAccountRequired: true` + +## 6.2 Account Creation with EBW Authentication + +**Pre-ACME phase (Blueprint steps 1-3):** + +1. The RP representative connects to the TSP's RA portal/service via the organisation's EBW. +2. **[MVP+]** The RA initiates an OID4VP request to the EBW, requesting the organisation's EBWOID (EAA). **[MVP]** This step MAY be performed out-of-band. +3. The EBW presents the EBWOID. +4. The RA validates the EBWOID and verifies the organisation's identity. +5. The RA validates the presence of RP in Trust Lists (*Blueprint step 5: RP list check.*) +6. Optionally (Blueprint step 4): **[MVP+]** the RA collects entitlements or types of entitlements in Trust Lists for WRPRC production. **[MVP]** RA MAY collects this information from RP out of band. +7. Optionally CA issues RPRC. +8. The RA issues EAB credentials to the EBW together with the optional RPRC if produced. + + +> [!NOTE] +> CS-RPAC_08: RPRC can be seen as a structured export of registration data, for instance formatted as a signed JWT. It seems then opportune to issue these just at the end of registration steps that check the content of the Trust List. + +**ACME phase:** + +8. The ACME Client generates an account key pair. +9. The ACME Client sends `newAccount` with the EAB binding. +10. The ACME Server validates the EAB and creates the account. + +## 6.3 Order Creation + +1. The ACME Client sends `newOrder` whose “identifier” value is ‘wrp-id’ and instanceID as an attribute + +2. The ACME Server MUST validate wrp-id coherence with EAB. + +2. The ACME Server creates the order in `pending` state(“201” created response) and returns `finalize` URL. +3. For multi-instance issuance: the ACME Server MAY verify that the `instanceId` (if provided) is not already in use by a currently valid WRPAC for the same `wrp-id`. + + +## 6.4 Order Finalization + +*Blueprint steps 6-9: order, issue, transmit, notify.* + +1. **MVP+** The RP’s backend generates a certificate key pair and CSR and pass it to the ACME Client **MVP** The ACME Client generates a certificate key pair and CSR. Per §7.3 item 4, RP MUST generate a distinct key pair for each Relying Party Instance. +2. The ACME Client sends the CSR to the `finalize` URL. +3. The ACME Server (RA) validates. +4. The CA submits a pre-certificate to CT logs and obtains SCTs. +5. the ACME Server (CA) generates WRPAC +6. The order transitions to `valid` with a `certificate` URL. +7. The ACME Server MAY send out-of-band notification to the RP representative. + +## 6.5 Certificate Download + +*Blueprint steps 10-11: authenticate + retrieve.* + +1. The ACME Client sends POST-as-GET to the `certificate` URL (authenticated via account key bound to EBW). +2. The ACME Server returns the PEM certificate chain. + +## 6.6 Certificate Revocation + +1. Client-initiated: `revokeCert` request. +2. Server-initiated: upon RP removal from the RP List, or upon revocation of a specific Relying Party Instance. +3. For multi-instance deployments: revocation of one instance's WRPAC MUST NOT affect WRPACs issued to other instances of the same WRP. + +> [!NOTE] +> CS-RPAC_09: Renewal is out of scope of this specification; implementations are only required to support initial issuance and revocation. + +# 7. Normative Requirements + +## 7.1 Common Requirements + +Both ACME Client and ACME Server **MUST**: + +1. Implement the ACME protocol per RFC 8555 [1]. +2. Use HTTPS for all communication. +3. Use JWS (RFC 7515 [8]) for all request payloads, with JWK Thumbprints computed per RFC 7638 [17] where required by ACME. +4. Implement nonce-based replay protection. + +## 7.2 CA / RA +###7.2.1 RA +1.**MVP+** RA MUST authenticate RP and authorised representative through OID4VP protocol; **MVP** SHOULD authenticate RP and authorised representative through OID4VP protocol. +2. RA MUST verify WRP presence in the WE BUILD RP Lists before EAB credentials issuance (Blueprint step 5). + +###7.2.2 ACME Server +The ACME Server **MUST**: + +1. Publish an ACME directory at a well-known URL (see §7.9 for IANA considerations). +2. Require EAB for all accounts (RFC 8555 §7.3.4 [1]). +3. Reject authorization if the `wrp-id` does not match with the EBWOID bound to the EAB (see item 9). +4. Issue WRPACs exclusively to authorized WRPs. +5. Support the order state transitions defined in RFC 8555 §7.1.6 "Status Changes" [1]. +6. Support `revokeCert`. +7. Accept instanceId when multi-instance support is advertised + + +The ACME Server **SHOULD**: + +1. Send out-of-band notification when certificates are ready (Blueprint step 9). +2. Support `keyChange`. + +## 7.3 ACME Client (WRP / EBW) + +The ACME Client **MUST**: + +1. Perform directory discovery. +2. Support EAB using credentials obtained from the RA after registration. +3. Supply a distinct key pair per Relying Party Instance and supply valid CSRs per §7.4. For single-instance issuance, a single key pair applies; for multi-instance issuance, a distinct certificate request including a distinct key pair MUST be post for each `instanceId`. This is the single normative key-separation rule applied by both the Client and the Server. +4. Validate the returned certificate chain. +5. Support `revokeCert`. + +The ACME Client **SHOULD**: + +2. Include an `instanceId` as one ‘identifier’ in orders for multi-instance deployments. + +## 7.4 Certificate Profile + +Issued WRPACs **MUST**: + +1. Be X.509 v3 public-key certificates conforming to RFC 5280 [10]. +2. Comply with ETSI TS 119 411-8 v1.1.1 [3] (Access Certificate Policy for EUDI Wallet Relying Parties), which in turn applies the NCP policy as specified in ETSI EN 319 411-1 v1.5.1 [12] and the WRPAC-specific policy identifiers `NCP-n-eudiwrp` (natural person) and `NCP-l-eudiwrp` (legal person). +3. Contain the WRP's legal name in the Subject field (CIR 2025/848 Annex I point 1 [2]). +4. Contain a user-friendly name where applicable (Annex I point 2). +5. Contain one unique WRP identifier (Annex I point 3), listed in the CP among EUID, LEI, National Trade Register number, or VAT identification number. +6. Include a certificate policy OID (Annex IV). +7. Include certification path information (URI). +8. Include a SAN extension containing contact information of RP as described in ETSI TS 119411-8 (6.6.1-07) +9. Include registration information per Annex I points 1, 2, and 8. +10. Express attribute content (e.g. `organizationIdentifier`, semantic identifiers) using the Subject DN and certificate-extension attributes defined in ETSI EN 319 412-1 [18] and ETSI TS 119 475 v1.2.1 clause 5 [4]. +11. Conform to ETSI TS 119 475 v1.2.1 [4]. +12. Use RSA (min. 3072 bits), ECDSA (P-256 or P-384), SHA-256 or stronger. +13. Have a validity period of **one year** from the date of issuance. Shorter validity periods are NOT used in WE BUILD interoperability testing. +14. Set Key Usage value at Digital Signature. +### 7.4.1 Subject Name Construction + +SERIAL NUMBER (2.5.4.5)= +Unique number calculated by CA to guarantee certificate unicity +CN = +“Friendly name”, either Company Name or Trade Name, easily understandable by end user +OU= +Optional intanceID +2.5.4.97 (organisation identifier)= +Wrp-id +O= +Organisation name +C= +Country + +For instance: + +1 CN=FICTIVE RELYING PARTY, +2 OU=Unit 01, +3 organizationIdentifier=NTRFR.123456789, +4 O=FICTIVE RELYING PARTY SAS, +5 C=FR + + +> [!NOTE] +> CS-RPAC_10: For interoperability testing, implementations SHOULD support both RSA and ECDSA. NCP as defined in ETSI EN 319 411-1 v1.5.1 [12] is the baseline policy level, with the WRPAC-specific extensions of TS 119 411-8 v1.1.1 [3] applied on top. The one-year validity period applies uniformly in the WE BUILD ITB+ test environment. + +> [!NOTE] +> CS-RPAC_11: WRPACs are X.509 public-key certificates; they are not X.509 Attribute Certificates. Attribute content in WRPACs is expressed through public-key certificate extensions and ETSI-defined Subject DN attributes (ETSI EN 319 412 series [18], ETSI TS 119 475 v1.2.1 clause 5 [4]). The Attribute Certificate profile defined in RFC 5755 is therefore not applicable to WRPACs and is not used in this specification. + +## 7.5 Certificate Transparency + +The ACME Server **MUST** log all WRPACs in CT logs and embed SCTs. + +> [!NOTE] +> CS-RPAC_12: RFC 9162 [7] ("Certificate Transparency Version 2.0", Experimental, December 2021) is the current IETF specification for CT and obsoletes RFC 6962. However, virtually all production CT log operators and browser CT policies currently implement RFC 6962. For interoperability testing in WE BUILD ITB+, implementations MAY use test CT logs and MAY rely on RFC 6962 where production tooling is not yet available for RFC 9162. Implementations MUST document which CT specification they implement. + +## 7.6 Revocation + +The ACME Server **MUST** support client-initiated (`revokeCert`) and server-initiated revocation (RP List removal or instance-specific revocation), and publish status via OCSP and/or CRL. + +For multi-instance deployments, revocation of one instance's WRPAC MUST be scoped to that instance and MUST NOT affect WRPACs issued to other instances of the same WRP. + +As per notification of removal of an RP from Trust List, RA SHALL without delay: +1.Delete EAB +2.Order revocation of all RP’s RPAC. + +> [!NOTE] +> CS-RPAC_13: For interoperability testing, RP List removal MAY be simulated. + +## 7.7 Trusted List Integration + +1. The test environment MUST include a simulated Trusted List containing the trust anchor certificate(s) of the CA(s) participating in the WE BUILD pilot. Individual WRPAC end-entity certificates are NOT listed in the Trusted List; trust is established through the CA trust anchor chain. +2. Wallet Units MUST verify WRPACs by building and validating the certificate chain up to a CA trust anchor present in the Trusted List. + +## 7.8 WE BUILD RP Lists + +In the WE BUILD pilot, participating TSPs acting as mock Registrars establish and maintain the Lists of authorized Relying Parties, in accordance with the Blueprint [5] MVP governance model. This is the functional equivalent of the national register of wallet-relying parties. + +1. The RA MUST check these lists as a precondition for EAB credential issuance. + +Each RP List entry MUST contain at minimum: + +* WRP unique identifier +* WRP legal name +* Authorization status (active / revoked) + +Each RP List entry SHOULD contain entitlements or types of entitlements. + +## 7.9 IANA Considerations + +This specification defines extensions to the ACME protocol that fall within IANA registries established by RFC 8555 and its successors, and introduces a custom URI suffix. For interoperability-testing purposes within WE BUILD ITB+, registration is not a prerequisite; for any future production use, the following registrations would be required. + +**ACME Identifier Types registry** (established by RFC 8555): the identifier type `wrp-id` (§5.2) would need to be registered. + +**Well-Known URI registry** (RFC 8615 [19]): the URI suffix `acme-eudi-wrpac` (§8.1) would need to be registered if the `/.well-known/` prefix is used. Alternatively, since RFC 8555 does not mandate the use of `/.well-known/` for the ACME directory, a conventional (non-`.well-known/`) path MAY be used, as shown in §8.1. Implementations intending to place the ACME directory under `/.well-known/acme-eudi-wrpac/` MUST complete the RFC 8615 registration process. + +**ACME directory metadata extensions**: the metadata fields `rprcCoIssuanceSupported` and `multiInstanceIssuanceSupported` (§8.1) are WE BUILD-specific extensions. The IANA "ACME Directory Metadata Fields" registry (established by RFC 8555) would need to be updated for any production deployment. + +# 8. Interface Definitions + +## 8.1 ACME Directory + +* **Method**: `GET` +* **URL**: `{base}/acme-eudi-wrpac/directory` (see §7.9 for URI considerations) + +```json +{ + "newNonce": "https://acme.example/new-nonce", + "newAccount": "https://acme.example/new-acct", + "newOrder": "https://acme.example/new-order", + "revokeCert": "https://acme.example/revoke-cert", + "keyChange": "https://acme.example/key-change", + "meta": { + "termsOfService": "https://acme.example/terms", + "externalAccountRequired": true, + "supportedIdentifierTypes": ["wrp-id"], + "rprcCoIssuanceSupported": true, + "multiInstanceIssuanceSupported": true + } +} +``` + +> [!NOTE] +> CS-RPAC_14: `rprcCoIssuanceSupported` and `multiInstanceIssuanceSupported` are WE BUILD extensions to the ACME directory metadata. The former indicates the server can co-issue WRPAC and WRPRC; the latter indicates support for per-instance issuance per the ARF v2.8 Relying Party Instance model [15]. Registration considerations for these extensions are described in §7.9. + +## 8.2 Account Management with EBW/EBWOID + +* **Method**: `POST` (JWS-signed) + +**Pre-condition**: EBW authentication + EBWOID validation completed; EAB credentials received. + +```json +{ + "termsOfServiceAgreed": true, + "contact": ["mailto:representative@rp.example"], + "externalAccountBinding": { + "protected": "", + "payload": "", + "signature": "" + } +} +``` + +## 8.3 Order Lifecycle + +**Create order (single instance):** + +```json +{ + "identifiers": [ + { "type": "wrp-id", "value": "NLKVK.12345678" } + ] +} +``` + +**Create order (multi-instance, with instanceId):** + +```json +{ + "identifiers": [ + { "type": "wrp-id", "value": "NLKVK.12345678" } + ], + "instanceId": "production-eu-west-1" +} +``` + +The `instanceId` value is an opaque string chosen by the ACME Client. It MUST be unique among all currently valid WRPACs issued to the same `wrp-id`. Recommended format: a short alphanumeric label identifying the deployment context (e.g., environment, region, or service name). + +**Order states** (per RFC 8555 §7.1.6 "Status Changes" [1]): `pending` ? `valid`. + +## 8.4 Finalize and Certificate + +**Finalize:** + +```json +{ "csr": "" } +``` + +**Order RPAC (WE BUILD):** + +```json +{ + "status": "valid", + "identifiers": [ + { "type": "wrp-id", "value": "NLKVK.12345678" } + ], + "instanceId": "production-eu-west-1", + "certificate": "https://acme.example/cert/cert012", + "finalize": "https://acme.example/order/ord456/finalize", + } +``` + +The `instanceId` field in the order response echoes the value from the order request. + +**Certificate download**: `application/pem-certificate-chain` per RFC 8555 §7.4.2 [1] (end-entity first). + +## 8.5 Revocation + +```json +{ "certificate": "", "reason": 0 } +``` + +For multi-instance deployments, revocation targets the specific certificate identified by the DER-encoded value. Other WRPACs issued to the same `wrp-id` are unaffected. + +> [!NOTE] +> CS-RPAC_15:This profile does not use ACME authorizations and challenges. Authorization is performed during EAB provisioning and account creation. + +# 9. Conformance + +An implementation **conforms as an ACME Server (TSP: RA + CA)** if it: + +1. Implements ACME per RFC 8555 [1] with the extensions in §5. +2. Publishes an ACME directory per §8.1. +3. Requires EAB with EBW/EBWOID verification (§5.3). +4. Supports `wrp-id` identifiers. +5. Issues certificates per §7.4, including compliance with ETSI TS 119 411-8 v1.1.1 [3] and the attribute mapping in ETSI TS 119 475 v1.2.1 clause 5 [4]. +6. Logs to CT logs (§7.5). +7. Supports revocation (§7.6). +8. Supports multi-instance issuance per §5.2, §7.2, and §8.3. +9. Implements interfaces per §8. + +An implementation **conforms as an ACME Client (WRP / EBW)** if it: + +1. Implements ACME per RFC 8555 [1] with the extensions in §5. +2. Supports EBW-based EAB provisioning (§5.3). +3. Creates accounts with EAB, orders with `wrp-id` (and optional `instanceId`). +4. Submits valid CSRs with distinct key pairs per instance, and validates certificate chains. +5. Supports revocation. + +An implementation **conforms as a test environment** if it: + +1. Provides WE BUILD RP Lists. +2. Provides a simulated Trusted List containing CA trust anchor certificate(s). +3. Provides a CT log (or test equivalent). +4. Supports EAB provisioning (or simulation per MVP scope). +5. Supports automated full-lifecycle testing, including multi-instance issuance scenarios. + +An **MVP+ conformant** implementation additionally: + +1. Implements the full OID4VP-based EBW authentication flow for EAB provisioning (§5.3, §6.2). +2. Accepts the organisation's EBWOID (EAA) as presented via the EBW during the pre-ACME authentication phase. + +Profiles for specific WE BUILD credential types MUST NOT relax these requirements. + +**Mapping of the WE BUILD Blueprint issuance workflow (derived from ETSI TS 119 475 v1.2.1 Annex D, Use Case 1: Integrated model [4]) to ACME protocol operations:** + +| Blueprint Step | Description | ACME Operation | Section | +|---|---|---|---| +| **1** | User authenticates to RA using EBW | EBW authentication ? EAB provisioning | §5.3, §8.2 | +| **2** | RA requests credentials | OID4VP request from RA to EBW [MVP+] | §5.3 | +| **3** | User supplies EAA (EBWOID) | EBW presents EBWOID to RA | §5.3 | +| **4** | RA collects additional WRPRC attributes | Collected during EAB provisioning | §6.2 | +| **5** | RA checks RP in authorized RP lists | — | §6.2 | +| **6** | RA orders issuance of both certificates | ACME `finalize` (CSR submission) | §8.5 | +| **7** | CA issues WRPAC| ACME Server generates certificates, order ? `valid` | §8.5 | +| **7** | CA issues WRPRC| —| §6.2 | +| **8** | CA transmits certificates to RA | Internal (ACME Server encapsulates RA+CA) | — | +| **9** | RA notifies user (e.g. by email) | Order polling: status ? `valid`. Optional out-of-band notification. | §8.5 | +| **10** | User authenticates via EBW | Certificate download authenticated via JWS-signed POST-as-GET (account key bound to EBW via EAB) | §8.5 | +| **11** | User retrieves WRPAC and WRPRC | ACME certificate download from `certificate` URL | §8.5 | + +> [!NOTE] +> CS-RPAC_16: The table above maps the eleven-step workflow used by the WE BUILD Blueprint [5] — which is derived from the integrated issuance model described in ETSI TS 119 475 v1.2.1 Annex D, Use Case 1 [4] — to the ACME protocol operations defined by this specification, including Registration Process and EAB credentials issuance. ETSI TS 119 475 Annex D is informative and presents four use cases (D.1 Integrated model, D.2 Registrar-initiated, D.3 RP-initiated, D.4 Provider-assisted); the eleven discrete steps enumerated above are a WE BUILD refinement of the D.1 flow and are not themselves a normative ETSI enumeration. + +**Key design decisions:** + +* **Steps 1-3** (EBW auth + EBWOID) are a **pre-ACME phase** producing EAB credentials. This cleanly separates organisational authentication from the machine protocol. Authorisation derives from control of the organisation's EBW and the EBWOID it presents; the EBWOID's `id`/`name` are matched against the order's `wrp-id` (§7.2 item 5). Full OID4VP-based EBW authentication is required for MVP+ conformance. +* **Step 4** (WRPRC attributes) is collected either during EAB provisioning. +* **Step 5** (RP list check) is performed by RA for EAB credentials issuance. +* **Steps 6-8** (order, issue, transmit) map to ACME `finalize` ? `pending` ? `valid`. +* **Steps 9-11** (notify, auth, retrieve) map to order polling and certificate download, with optional email notification. + +> [!NOTE] +> CS-RPAC_17: The Blueprint specifies RA and CA as separate actors. In this ACME profile they MAY be encapsulated in a single endpoint. Implementations SHOULD separate them internally. This is consistent with real-world ACME CAs where the RA front-end and CA signing backend are separate systems behind one protocol interface. + +# References + +[1] IETF (2019) RFC 8555 — Automatic Certificate Management Environment (ACME). Standards Track, March 2019. https://www.rfc-editor.org/rfc/rfc8555 + +[2] European Commission (2025) Commission Implementing Regulation (EU) 2025/848 of 6 May 2025 laying down rules for the application of Regulation (EU) No 910/2014 of the European Parliament and of the Council as regards the registration of wallet-relying parties. OJ L, 2025/848, 7.5.2025. Applies from 24 December 2026. http://data.europa.eu/eli/reg_impl/2025/848/oj + +[3] ETSI (2025) ETSI TS 119 411-8 v1.1.1 (2025-10) — Electronic Signatures and Trust Infrastructures (ESI); Policy and security requirements for Trust Service Providers issuing certificates; Part 8: Access Certificate Policy for EUDI Wallet Relying Parties. + +[4] ETSI (2026) ETSI TS 119 475 v1.2.1 (2026-03) — Electronic Signatures and Trust Infrastructures (ESI); Relying party attributes supporting EUDI Wallet user's authorisation decisions. In particular Annex D, Use Case 1 (Integrated model). + +[5] WE BUILD (2026) WP4 Architecture Blueprint — RPAC/RPRC Documentation. https://webuild-consortium.github.io/wp4-architecture/blueprint/blueprint.html + +[6] WE BUILD (2025) ITB+ Reference Specification. https://github.com/webuild-consortium/wp4-interop-test-bed/ + +[7] IETF (2021) RFC 9162 — Certificate Transparency Version 2.0. Experimental, December 2021. Obsoletes RFC 6962. https://www.rfc-editor.org/rfc/rfc9162 + +[8] IETF (2015) RFC 7515 — JSON Web Signature (JWS). Standards Track, May 2015. https://www.rfc-editor.org/rfc/rfc7515 + +[9] European Commission (2025/2026) EC TS5 — Common formats and API for RP registration information. Draft, eu-digital-identity-wallet/eudi-doc-standards-and-technical-specifications repository. + +[10] IETF (2008) RFC 5280 — Internet X.509 Public Key Infrastructure Certificate and Certificate Revocation List (CRL) Profile. Standards Track, May 2008. https://www.rfc-editor.org/rfc/rfc5280 + +[11] [Reserved] + +[12] ETSI (2025) ETSI EN 319 411-1 v1.5.1 (2025-04) — Electronic Signatures and Trust Infrastructures (ESI); Policy and security requirements for Trust Service Providers issuing certificates; Part 1: General requirements. + +[13] IETF (2003) RFC 3647 — Internet X.509 Public Key Infrastructure Certificate Policy and Certification Practices Framework. Informational, November 2003. https://www.rfc-editor.org/rfc/rfc3647 + +[14] Regulation (EU) No 910/2014, as amended by Regulation (EU) 2024/1183 of the European Parliament and of the Council of 11 April 2024 establishing the European Digital Identity Framework. + +[15] EUDI Wallet Architecture and Reference Framework v2.8.0. https://eu-digital-identity-wallet.github.io/eudi-doc-architecture-and-reference-framework/ + +[16] European Commission (2025/2026) EC TS6 — Common set of RP registration information. Draft, eu-digital-identity-wallet/eudi-doc-standards-and-technical-specifications repository. + +[17] IETF (2015) RFC 7638 — JSON Web Key (JWK) Thumbprint. Standards Track, September 2015. https://www.rfc-editor.org/rfc/rfc7638 + +[18] ETSI EN 319 412-1 — Electronic Signatures and Trust Infrastructures (ESI); Certificate Profiles; Part 1: Overview and common data structures. + +[19] IETF (2019) RFC 8615 — Well-Known Uniform Resource Identifiers (URIs). Standards Track, May 2019. https://www.rfc-editor.org/rfc/rfc8615 + +[20] WE BUILD Attestation Rulebooks Catalog — EBWOID Attestation Rulebook (rb-ebwoid). https://github.com/webuild-consortium/webuild-attestation-rulebooks-catalog/tree/main/rulebooks/rb-ebwoid From c93e35258520f0fe81b53385ab3ab3a3976e8000 Mon Sep 17 00:00:00 2001 From: FCHASSERY Date: Tue, 25 Aug 2026 18:34:37 +0200 Subject: [PATCH 10/14] Update cs-06-issuance-of-relying-party-access-and-registration-certificates.md This version adds an alternative to full automated issuance process in order to serve RP that does not use EBW or does not wish to integrate an ACME client in their IT systems. --- ...ty-access-and-registration-certificates.md | 108 ++++++++++++------ 1 file changed, 73 insertions(+), 35 deletions(-) diff --git a/conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md b/conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md index c1f6e14e..478947db 100644 --- a/conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md +++ b/conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md @@ -1,7 +1,7 @@ # WE BUILD - Conformance Specification: Issuance of Relying Party Access and Registration Certificates Version 1.0 -Date: 31 juillet 2026 +Date: 25 Août 2026 Original Author: Jilles Van Oossanen Modified Proposal by : François CHASSERY @@ -16,13 +16,9 @@ Modified Proposal by : François CHASSERY - [5.2 WRPAC Identifier Type](#52-wrpac-identifier-type) - [5.3 EBW-based Account Binding](#53-ebw-based-account-binding) - [6. High-level Flows](#6-high-level-flows) - - [6.1 Directory Discovery](#61-directory-discovery) - - [6.2 Account Creation with EBW Authentication](#62-account-creation-with-ebw-authentication) - - [6.3 Order Creation](#63-order-creation) - - [6.4 Order Finalization](#64-order-finalization) - - [6.5 Certificate Download](#65-certificate-download) - - [6.6 Certificate Revocation](#66-certificate-revocation) -- [7. Normative Requirements](#7-normative-requirements) + - [6.1 Automated Issuance Process](#61-automated-issuance-process) + - [6.2 Direct Isssuance Process](#62-direct-issuance-process) + - [7. Normative Requirements](#7-normative-requirements) - [7.1 Common Requirements](#71-common-requirements) - [7.2 ACME Server (CA / RA)](#72-acme-server-ca--ra) - [7.3 ACME Client (WRP / EBW)](#73-acme-client-wrp--ebw) @@ -45,7 +41,7 @@ Modified Proposal by : François CHASSERY # 1. Introduction -This document defines the **WE BUILD Consortium Conformance Specification (CS)** for the issuance of Wallet-Relying Party Access Certificates (WRPACs) and, where applicable, Wallet-Relying Party Registration Certificates (WRPRCs) within the European Digital Identity Wallet ecosystem, using a protocol based on the Automatic Certificate Management Environment (ACME) as defined in RFC 8555 [1]. +This document defines the **WE BUILD Consortium Conformance Specification (CS)** for the issuance of Wallet-Relying Party Access Certificates (WRPACs) and, where applicable, Wallet-Relying Party Registration Certificates (WRPRCs) within the European Digital Identity Wallet ecosystem, and proposes either an automated issuance process using a protocol based on the Automatic Certificate Management Environment (ACME) as defined in RFC 8555 [1], or a direct issuance process relying on Web interfaces, electronic identifications means and digital signature. It profiles: @@ -57,6 +53,8 @@ It profiles: This specification uses the ACME protocol as a **technical implementation** for automation of the integrated issuance model described in ETSI TS 119 475 v1.2.1 Annex D, Use Case 1 [4], as adopted by the WE BUILD Blueprint [5]. Section 9 provides an explicit mapping between the WE BUILD Blueprint issuance workflow and the ACME protocol operations. +An alternative issuance process is described to allow WRP to obtain WRPC without using EBW nor ACME client. + This specification focuses **only on direct issuance** of WRPACs to registered Wallet-Relying Parties. The ACME flow is preceded with EBW-based authentication and EBWOID verification to align with the Blueprint's requirements for user authentication via European Business Wallets. This document is used to build the WE BUILD Interoperability Test Bed Plus (ITB+) [6]. > [!IMPORTANT] @@ -72,7 +70,7 @@ This specification defines: * An ACME protocol profile (based on RFC 8555) for the automated issuance of X.509-based Wallet-Relying Party Access Certificates, aligned with the WE BUILD Blueprint issuance process * Support for multi-instance issuance, enabling a single WRP to obtain separate WRPACs for multiple Relying Party Instances per the EUDI Wallet ARF v2.8 [15] * Requirements for: - * ACME Servers (TSP Certificate Authorities and Registration Authorities) + * ACME Servers operated by TSP acting as Certificate Authorities and Registration Authorities) * ACME Clients (Wallet-Relying Parties) * Protocol flows for: * Optional WRPRC issuance after registration of an authorized WRP @@ -83,7 +81,7 @@ This specification defines: This specification does **not** cover: * Intermediary or multi-party issuance -* Standalone WRPRC issuance (covered in a separate CS) +* Standalone WRPRC issuance * Production deployment requirements (conformity assessment, CAB audits, national policy extensions) * Proximity use cases for certificate presentation @@ -103,6 +101,8 @@ The following terminology applies throughout this specification: * **EAA** — Electronic Attestation of Attributes. * **EBWOID** — European Business Wallet Organisational Identification Data, as defined in the WE BUILD EBWOID Attestation Rulebook [20]. Carries the organisation's unique identifier (`id`) and official name (`name`). * **Authorised representative** or **RP representative**: Individual acting on behalf of the RP +* **Legal Representative** or **LR**: a natural person authorized by law or record registration to act on behalf of a legal person. +* **Power of Attorney** or **POA**: written document digitally signed by the RL with an advanced or qualified signature to establish that RP representative is appointed to act on behalf of the RP. * **RP’s BACKEND**: IS of RP * **RP’s FRONTEND**: ACME client of RP * **TRUST LIST**: Trusted registry of accredited Relying Parties @@ -113,23 +113,26 @@ This specification uses the following roles, mapped to both the ACME protocol an | ACME Role | Blueprint Role | Description | |---|---|---| +| **User + EID** | The RP representative using an Electronic Identification Means of level substantial or higher as per eIDAS regulation. The EID needs to be completed by POA in this specification in the aim of user-authentication and proof of authority for account creation| | **User + EBW** | The RP representative using an European Business Wallet. The EBW plays a role in this specification of a user-authentication authority for EBW-based External Account Binding per RFC 8555 §7.3.4 [1]. | -| **ACME Server** | **RA + CA** | The combined Registration Authority and Certificate Authority. The RA function handles identity verification, EBWOID validation, and RP list checks. The CA function handles certificate generation and signing. These MAY be separate systems behind a single ACME endpoint. | +| **ACME Server** | **RA + CA** | The combined Registration Authority and Certificate Authority. For automated issuance process, the RA function handles identity verification, EBWOID validation, and RP list checks. The CA function handles certificate generation and signing. These MAY be separate systems behind a single ACME endpoint. | |**ACME CLIENT** **RP’s FRONTEND**| — | host or invocation environment for the ACME Client software | | — | **Mock Registrar (RA/TSP)** | In the WE BUILD pilot, participating TSPs acting as mock Registrars maintain the Lists of authorised RPs. This role is functionally equivalent to the Member State Registrar in the production eIDAS ecosystem. In production, this function is performed by the national Registrar of the Member State concerned per the eIDAS framework. | | **Wallet Unit** | — | The EUDI/Business Wallet that verifies WRPACs during RP authentication. Not involved in issuance. | Detailed role descriptions: -* **User (RP Representative or authorised representative):** A natural person operating on behalf of the Wallet-Relying Party via the organisation's EBW. Authenticates via the EBW, which presents the organisation's EBWOID attestation (an EAA). Authorisation to obtain a certificate for the WRP follows from control of the organisation's EBW; this profile does not require a separate Power of Attorney or representative attestation. -* **European Business Wallet (EBW):** The wallet application used to authenticate to the RA -* **Registration Authority (RA):** The TSP component that verifies the organisation's identity by validating the EBWOID, requests additional attributes for the RPRC, and checks the RP's presence in the authorised RP lists. -* **Certificate Authority (CA):** The TSP component that generates and signs WRPAC and WRPRC certificates. Implemented as the ACME Server's certificate issuance backend. +* **User (RP Representative or authorised representative):** A natural person operating on behalf of the Wallet-Relying Party via the organisation's EBW, or on interfaces of RPC Provider. +When WRP is using an EBW, User Authenticates via the EBW, which presents the organisation's EBWOID attestation (an EAA). Authorisation to obtain a certificate for the WRP results from control of the organisation's EBW; this profile does not require a separate Power of Attorney or representative attestation. +When the WRP does not use EBW, User Authenticates via its EID, and presents its POA. Authorisation to obtain a certificate for the WRP results from control of the POA together with the identity of the User. +* **European Business Wallet (EBW):** The wallet application used to authenticate to the RA in the automated issuance process. +* **Registration Authority (RA):** The TSP component that verifies the organisation's identity, requests additional attributes for the RPRC, and checks the RP's presence in the authorised RP lists. +* **Certificate Authority (CA):** The TSP component that generates and signs WRPAC and WRPRC certificates. Implemented as the ACME Server's certificate issuance backend in the automated issuance process. * **Mock Registrar:** In the WE BUILD pilot, participating TSPs acting as mock Registrars establish and maintain the Lists of authorized Relying Parties. These lists serve as the functional equivalent of the national register of wallet-relying parties per the Blueprint [5] MVP governance model. The RA checks these lists during authorization. During ITB+ conformance testing, the WE BUILD RP List is served by the mock Registrar; in production, by the national Registrar of the Member State concerned. # 5. Protocol Overview -The WE BUILD WRPAC issuance protocol uses the standard ACME framework (RFC 8555) where FQDN challenge will be disabled, in order to implement the integrated issuance model described in ETSI TS 119 475 v1.2.1 Annex D, Use Case 1 [4] as adopted by the WE BUILD Blueprint [5]. +The WE BUILD WRPAC automated issuance protocol uses the standard ACME framework (RFC 8555) where domain-control challenge will be disabled, in order to implement the integrated issuance model described in ETSI TS 119 475 v1.2.1 Annex D, Use Case 1 [4] as adopted by the WE BUILD Blueprint [5]. The key adaptations are: @@ -140,15 +143,15 @@ Trust List verification SHALL occur before EAB issuance. * **Multi-instance issuance**: a single WRP (`wrp-id`) MAY obtain multiple WRPACs, one per Relying Party Instance. Each instance is identified by an optional `instanceId` in the order (see §5.2). * * **Certificate profile**: the issued certificate is an X.509 v3 WRPAC conforming to CIR 2025/848 Annex IV and ETSI TS 119 411-8 v1.1.1 [3], with attribute content as specified in ETSI TS 119 475 v1.2.1 clause 5 [4]. * **Certificate Transparency**: the ACME Server logs all issued certificates per RFC 9162 [7] (see §7.5 for deployment guidance). -* **ACME Challenge**: this challenge is disabled and replaced by an internal control by CA that the DN of RPAC contains EBWOID without any change. +* **ACME Challenge**: this challenge is not used and replaced by an internal control by CA that the DN of RPAC contains EBWOID without any change. All ACME messages are JSON payloads signed via JWS (RFC 7515 [8]), using JWK Thumbprints as specified in RFC 7638 [17], transported over HTTPS. > [!NOTE] -> CS-RPAC_03: The choice of ACME provides a proven, standardised, and automatable protocol for certificate lifecycle management. It implements the integrated issuance workflow described in ETSI TS 119 475 v1.2.1 Annex D, Use Case 1 (see Section 9) while enabling interoperability testing with off-the-shelf ACME tooling. The RA functions defined in the Blueprint are encapsulated within the ACME Server. +> CS-RPAC_03: The choice of ACME provides a proven, standardised, and automatable protocol for automated certificate lifecycle management. It implements the integrated issuance workflow described in ETSI TS 119 475 v1.2.1 Annex D, Use Case 1 (see Section 9) while enabling interoperability testing with off-the-shelf ACME tooling. The RA functions defined in the Blueprint may be encapsulated within the ACME Server. > CS-RPAC_04: The choice of keeping standard ACME protocol but adding wrp-identifier will avoid to use a specific EWB for RP, and leave opened the opportunity of a complete ID and mandate verification by RA in the future. ->CS-RPAC_05: In the context of WEBUILD, RA will presume that the Id of the RP’s representative is true without performing a true identity verification conform to NCP Policy. ->CS-RPAC_06: In the context of WEBUILD, RA will consider that the authorised representative has an implicit mandate for requesting an NCP certificate. +>CS-RPAC_05: In the context of automated issuance for WEBUILD, RA will presume that the Id of the RP’s representative is true without performing a true identity verification conform to NCP Policy. +>CS-RPAC_06: In the context of automated issuance for WEBUILD, RA will consider that the authorised representative has an implicit mandate for requesting an NCP certificate. ## 5.1 ACME Resource Model @@ -170,7 +173,7 @@ newAccount newOrder revokeCert keyChange ``` -This profile follows RFC 8555 with the extensions defined in §5.2, §5.3. except that ACME Authorization and Challenge resources are not used. Authorization is performed during EAB provisioning and account creation. +This profile follows RFC 8555 with the extensions defined in §5.2, §5.3. except that it intentionally replaces domain control validation with organisation control validation performed during EAB provisioning. Authorization is performed during EAB provisioning and account creation. ## 5.2 WRPAC Identifier Type @@ -190,7 +193,7 @@ This profile follows RFC 8555 with the extensions defined in §5.2, §5.3. excep **Multi-instance issuance**: A single WRP identified by one `wrp-id` value MAY obtain multiple WRPACs, one per Relying Party Instance, in accordance with the Relying Party Instance model described in the EUDI Wallet ARF v2.8 [15]. Each order MAY include an optional `instanceId` string that uniquely identifies the specific Relying Party Instance within the WRP's deployment. When `instanceId` is provided: * If multi issuance is advertised the ACME Server MUST verify that the `instanceId` is distinct from all other currently valid WRPACs issued to the same `wrp-id`. -* If multi issuance is advertised the issued WRPAC MUST include the `instanceId` as an Organisational Unit in the Subject's Common Name. +* If multi issuance is advertised the issued WRPAC MUST include the `instanceId` as an Organisational Unit in the Subject's Distinguished Name. ## 5.3 EBW-based Account Binding @@ -225,12 +228,14 @@ Per RFC 8555 §7.3.4 [1], the `externalAccountBinding` value is a flattened JWS # 6. High-level Flows -## 6.1 Directory Discovery +## 6.1 Automated Issuance Process + +### 6.1.1 Directory Discovery 1. The ACME Client fetches the directory document via HTTPS GET. 2. The directory includes metadata: `externalAccountRequired: true` -## 6.2 Account Creation with EBW Authentication +### 6.1.2 Account Creation with EBW Authentication **Pre-ACME phase (Blueprint steps 1-3):** @@ -243,17 +248,13 @@ Per RFC 8555 §7.3.4 [1], the `externalAccountBinding` value is a flattened JWS 7. Optionally CA issues RPRC. 8. The RA issues EAB credentials to the EBW together with the optional RPRC if produced. - -> [!NOTE] -> CS-RPAC_08: RPRC can be seen as a structured export of registration data, for instance formatted as a signed JWT. It seems then opportune to issue these just at the end of registration steps that check the content of the Trust List. - **ACME phase:** 8. The ACME Client generates an account key pair. 9. The ACME Client sends `newAccount` with the EAB binding. 10. The ACME Server validates the EAB and creates the account. -## 6.3 Order Creation +### 6.1.3 Order Creation 1. The ACME Client sends `newOrder` whose “identifier” value is ‘wrp-id’ and instanceID as an attribute @@ -263,7 +264,7 @@ Per RFC 8555 §7.3.4 [1], the `externalAccountBinding` value is a flattened JWS 3. For multi-instance issuance: the ACME Server MAY verify that the `instanceId` (if provided) is not already in use by a currently valid WRPAC for the same `wrp-id`. -## 6.4 Order Finalization +### 6.1.4 Order Finalization *Blueprint steps 6-9: order, issue, transmit, notify.* @@ -275,20 +276,58 @@ Per RFC 8555 §7.3.4 [1], the `externalAccountBinding` value is a flattened JWS 6. The order transitions to `valid` with a `certificate` URL. 7. The ACME Server MAY send out-of-band notification to the RP representative. -## 6.5 Certificate Download +### 6.1.5 Certificate Download *Blueprint steps 10-11: authenticate + retrieve.* 1. The ACME Client sends POST-as-GET to the `certificate` URL (authenticated via account key bound to EBW). 2. The ACME Server returns the PEM certificate chain. -## 6.6 Certificate Revocation +### 6.1.6 Certificate Revocation 1. Client-initiated: `revokeCert` request. 2. Server-initiated: upon RP removal from the RP List, or upon revocation of a specific Relying Party Instance. 3. For multi-instance deployments: revocation of one instance's WRPAC MUST NOT affect WRPACs issued to other instances of the same WRP. +## 6.2 Direct Issuance Process + +### 6.2.1 Account Creation with EID Authentication + +**Registration phase (Blueprint steps 1-3):** + +1. The User connects to the TSP's RA portal/service. +2. The User fills in RP identification information (i.e. Company Name, Organisation Identifier, address and points of contact). +3. The User registers himself as the Authorised Representative +4. The User uploads WRP evidences (proof of existence or registration, depending on the RP’s country regulation, and the POA) + +### 6.2.2 Order Creation + +1. The User describes the certificate receiver. +2. The User fills in a RPAC certificate application +3. Optionally the User fills in a RPRC certificate application. +4. The user set a complex password for certificate retrieval. + +### 6.2.3 Order Validation + +1. The RA validates the RP Identifier and verifies the organisation's identity. +2. The RA validates the presence of RP in Trust Lists (*Blueprint step 5: RP list check.*) +3. Optionally (Blueprint step 4): **[MVP+]** the RA collects entitlements or types of entitlements in Trust Lists for WRPRC production. **[MVP]** RA MAY collects this information from RP out of band. + +### 6.2.4 Certificate Issuance + +*Blueprint steps 10-11: authenticate + retrieve.* + +1. Optionally CA issues RPRC. +2. The CA issues RPAC as a PKCS#12 enciphered by the password selected at step 5. +3. The RA sends the RPAC together with the optional RPRC if produced by e-mail to the certificate receiver. +4. The RA send to the certificate receiver its revocation credentials and procedure. + +### 6.2.5 Certificate Revocation + +1. Certificate receiver may use its revocation credentials to revoke RPAC according to RPC issuer procedure. + > [!NOTE] +> CS-RPAC_08: RPRC can be seen as a structured export of registration data, for instance formatted as a signed JWT. It seems then opportune to issue these just at the end of registration steps that check the content of the Trust List. > CS-RPAC_09: Renewal is out of scope of this specification; implementations are only required to support initial issuance and revocation. # 7. Normative Requirements @@ -318,7 +357,6 @@ The ACME Server **MUST**: 6. Support `revokeCert`. 7. Accept instanceId when multi-instance support is advertised - The ACME Server **SHOULD**: 1. Send out-of-band notification when certificates are ready (Blueprint step 9). @@ -400,7 +438,7 @@ The ACME Server **MUST** support client-initiated (`revokeCert`) and server-init For multi-instance deployments, revocation of one instance's WRPAC MUST be scoped to that instance and MUST NOT affect WRPACs issued to other instances of the same WRP. As per notification of removal of an RP from Trust List, RA SHALL without delay: -1.Delete EAB +1.Delete EAB when RP has registered with the automated process 2.Order revocation of all RP’s RPAC. > [!NOTE] From db16cb90251f94f9cbf3bd180c32a32fb9fb8158 Mon Sep 17 00:00:00 2001 From: FCHASSERY Date: Tue, 25 Aug 2026 18:35:03 +0200 Subject: [PATCH 11/14] Update cs-06-issuance-of-relying-party-access-and-registration-certificates.md --- ...nce-of-relying-party-access-and-registration-certificates.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md b/conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md index 478947db..806cfd03 100644 --- a/conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md +++ b/conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md @@ -1,6 +1,6 @@ # WE BUILD - Conformance Specification: Issuance of Relying Party Access and Registration Certificates -Version 1.0 +Version 1.1 Date: 25 Août 2026 Original Author: Jilles Van Oossanen Modified Proposal by : François CHASSERY From 6acaf39933df69fd298df8d17c681f88a9cd7e54 Mon Sep 17 00:00:00 2001 From: FCHASSERY Date: Tue, 25 Aug 2026 18:59:46 +0200 Subject: [PATCH 12/14] Update cs-06-issuance-of-relying-party-access-and-registration-certificates.md redactional corrections --- ...g-party-access-and-registration-certificates.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md b/conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md index 806cfd03..0aadb9ae 100644 --- a/conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md +++ b/conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md @@ -20,8 +20,8 @@ Modified Proposal by : François CHASSERY - [6.2 Direct Isssuance Process](#62-direct-issuance-process) - [7. Normative Requirements](#7-normative-requirements) - [7.1 Common Requirements](#71-common-requirements) - - [7.2 ACME Server (CA / RA)](#72-acme-server-ca--ra) - - [7.3 ACME Client (WRP / EBW)](#73-acme-client-wrp--ebw) + - [7.2 ACME Server (CA / RA)](#72-acme-server-ca-ra) + - [7.3 ACME Client (WRP / EBW)](#73-acme-client-wrp-ebw) - [7.4 Certificate Profile](#74-certificate-profile) - [7.5 Certificate Transparency](#75-certificate-transparency) - [7.6 Revocation](#76-revocation) @@ -113,8 +113,8 @@ This specification uses the following roles, mapped to both the ACME protocol an | ACME Role | Blueprint Role | Description | |---|---|---| -| **User + EID** | The RP representative using an Electronic Identification Means of level substantial or higher as per eIDAS regulation. The EID needs to be completed by POA in this specification in the aim of user-authentication and proof of authority for account creation| -| **User + EBW** | The RP representative using an European Business Wallet. The EBW plays a role in this specification of a user-authentication authority for EBW-based External Account Binding per RFC 8555 §7.3.4 [1]. | +| **User + EID** | User EID |The RP representative using an Electronic Identification Means of level substantial or higher as per eIDAS regulation. The EID needs to be completed by POA in this specification in the aim of user-authentication and proof of authority for account creation| +| **User + EBW** | User EBW |The RP representative using an European Business Wallet. The EBW plays a role in this specification of a user-authentication authority for EBW-based External Account Binding per RFC 8555 §7.3.4 [1]. | | **ACME Server** | **RA + CA** | The combined Registration Authority and Certificate Authority. For automated issuance process, the RA function handles identity verification, EBWOID validation, and RP list checks. The CA function handles certificate generation and signing. These MAY be separate systems behind a single ACME endpoint. | |**ACME CLIENT** **RP’s FRONTEND**| — | host or invocation environment for the ACME Client software | | — | **Mock Registrar (RA/TSP)** | In the WE BUILD pilot, participating TSPs acting as mock Registrars maintain the Lists of authorised RPs. This role is functionally equivalent to the Member State Registrar in the production eIDAS ecosystem. In production, this function is performed by the national Registrar of the Member State concerned per the eIDAS framework. | @@ -342,11 +342,11 @@ Both ACME Client and ACME Server **MUST**: 4. Implement nonce-based replay protection. ## 7.2 CA / RA -###7.2.1 RA -1.**MVP+** RA MUST authenticate RP and authorised representative through OID4VP protocol; **MVP** SHOULD authenticate RP and authorised representative through OID4VP protocol. +### 7.2.1 RA +1. **MVP+** RA MUST authenticate RP and authorised representative through OID4VP protocol; **MVP** SHOULD authenticate RP and authorised representative through OID4VP protocol. 2. RA MUST verify WRP presence in the WE BUILD RP Lists before EAB credentials issuance (Blueprint step 5). -###7.2.2 ACME Server +### 7.2.2 ACME Server The ACME Server **MUST**: 1. Publish an ACME directory at a well-known URL (see §7.9 for IANA considerations). From 8a6589b3873b1c84bb65ed6efbace89f2f45557a Mon Sep 17 00:00:00 2001 From: FCHASSERY Date: Tue, 25 Aug 2026 19:02:26 +0200 Subject: [PATCH 13/14] Update cs-06-issuance-of-relying-party-access-and-registration-certificates.md redactionals --- ...e-of-relying-party-access-and-registration-certificates.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md b/conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md index 0aadb9ae..e6bcc92f 100644 --- a/conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md +++ b/conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md @@ -20,8 +20,8 @@ Modified Proposal by : François CHASSERY - [6.2 Direct Isssuance Process](#62-direct-issuance-process) - [7. Normative Requirements](#7-normative-requirements) - [7.1 Common Requirements](#71-common-requirements) - - [7.2 ACME Server (CA / RA)](#72-acme-server-ca-ra) - - [7.3 ACME Client (WRP / EBW)](#73-acme-client-wrp-ebw) + - [7.2 ACME Server (CA and RA)](#72-acme-server-ca-ra) + - [7.3 ACME Client (WRP and EBW)](#73-acme-client-wrp-ebw) - [7.4 Certificate Profile](#74-certificate-profile) - [7.5 Certificate Transparency](#75-certificate-transparency) - [7.6 Revocation](#76-revocation) From aad9b9c4147cfbdf0e722f73629d29695ceffab6 Mon Sep 17 00:00:00 2001 From: FCHASSERY Date: Tue, 25 Aug 2026 19:11:30 +0200 Subject: [PATCH 14/14] Update cs-06-issuance-of-relying-party-access-and-registration-certificates.md redactionals --- ...nce-of-relying-party-access-and-registration-certificates.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md b/conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md index e6bcc92f..f5e06eb5 100644 --- a/conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md +++ b/conformance-specs/cs-06-issuance-of-relying-party-access-and-registration-certificates.md @@ -318,7 +318,7 @@ Per RFC 8555 §7.3.4 [1], the `externalAccountBinding` value is a flattened JWS *Blueprint steps 10-11: authenticate + retrieve.* 1. Optionally CA issues RPRC. -2. The CA issues RPAC as a PKCS#12 enciphered by the password selected at step 5. +2. The CA issues RPAC as a PKCS#12 enciphered by the password selected at §6.2.2 step 4. 3. The RA sends the RPAC together with the optional RPRC if produced by e-mail to the certificate receiver. 4. The RA send to the certificate receiver its revocation credentials and procedure.