diff --git a/.github/workflows/run-backend-bdd-tests.yml b/.github/workflows/run-backend-bdd-tests.yml new file mode 100644 index 000000000..52330582e --- /dev/null +++ b/.github/workflows/run-backend-bdd-tests.yml @@ -0,0 +1,101 @@ +name: "Run backend BDD tests" + +on: + pull_request: + types: [opened, synchronize] + paths: + - "backend/**" + - "!backend/README.md" + - "!backend/.*" + - "backend/.eslintrc.js" + workflow_call: + +jobs: + run-backend-bdd-tests: + name: Run BDD tests + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Free up disk space + run: | + sudo rm -rf /usr/share/dotnet + sudo rm -rf /opt/ghc + sudo rm -rf "/usr/local/share/boost" + sudo rm -rf "$AGENT_TOOLSDIRECTORY" + docker system prune -af + + - name: ☁️ Checkout source + uses: actions/checkout@v3 + - name: Install uv + uses: astral-sh/setup-uv@v5 + - name: Install Python + run: uv python install + - uses: KengoTODA/actions-setup-docker-compose@v1 + if: ${{ env.ACT }} + name: Install `docker compose` for local simulations + with: + version: "2.14.2" + - name: 🔧 Setup Node 20 + uses: actions/setup-node@v3 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: backend/package-lock.json + - name: Install dependencies + run: npm install + working-directory: backend + + - name: Output .env file + run: | + cp .env.example .env + echo "ACME_DEVELOPMENT_MODE=true" >> .env + echo "ACME_FEATURE_ENABLED=true" >> .env + echo "ACME_DEVELOPMENT_HTTP01_CHALLENGE_HOST_OVERRIDES={\"localhost\": \"host.docker.internal:8087\"}" >> .env + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver-opts: | + image=moby/buildkit:latest + - name: Build Infisical backend Docker image with caching + uses: docker/bake-action@v5 + timeout-minutes: 30 + with: + files: docker-compose.bdd.yml + targets: backend + load: true + # Uncomment this to force a rebuild of the image + # no-cache: true + set: | + *.cache-from=type=gha,scope=infisical-backend-bdd-tests + *.cache-to=type=gha,mode=max,scope=infisical-backend-bdd-tests + - name: Start Infisical + run: docker compose -f docker-compose.bdd.yml up -d + - name: Wait for API to be ready + uses: nick-fields/retry@v3 + with: + timeout_seconds: 60 + max_attempts: 30 + command: | + curl -f -X GET http://localhost:8080/api/v1/admin/config + - name: Run bdd tests + run: npm run test:bdd + working-directory: backend + env: + INFISICAL_API_URL: http://localhost:8080 + BOOTSTRAP_INFISICAL: "1" + - name: cleanup + run: | + docker compose -f "docker-compose.bdd.yml" down + - name: Dump backend logs + if: always() # Ensures this runs even if previous steps fail + run: | + mkdir -p logs + docker compose -f docker-compose.bdd.yml logs backend > logs/backend.log 2>&1 || true + - name: Upload backend logs as artifact + if: always() # Always upload, even on failure/cancellation + uses: actions/upload-artifact@v4 + with: + name: backend-logs-${{ github.run_id }} + path: logs/backend.log + retention-days: 7 + if-no-files-found: warn diff --git a/.gitignore b/.gitignore index f2a23324b..b4e9a07c2 100644 --- a/.gitignore +++ b/.gitignore @@ -71,5 +71,6 @@ frontend-build cli/infisical-merge cli/test/infisical-merge /backend/binary +backend/bdd/.bdd-infisical-bootstrap-result.json /npm/bin diff --git a/backend/bdd/features/environment.py b/backend/bdd/features/environment.py index b355f98ae..52615036a 100644 --- a/backend/bdd/features/environment.py +++ b/backend/bdd/features/environment.py @@ -1,26 +1,200 @@ +import json import os +import pathlib import httpx from behave.runner import Context from dotenv import load_dotenv +from faker import Faker +import logging load_dotenv() +logger = logging.getLogger(__name__) BASE_URL = os.environ.get("INFISICAL_API_URL", "http://localhost:8080") PROJECT_ID = os.environ.get("PROJECT_ID") CERT_CA_ID = os.environ.get("CERT_CA_ID") CERT_TEMPLATE_ID = os.environ.get("CERT_TEMPLATE_ID") AUTH_TOKEN = os.environ.get("INFISICAL_TOKEN") +BOOTSTRAP_INFISICAL = int(os.environ.get("BOOTSTRAP_INFISICAL", 0)) + + +# Called mostly from a CI to setup the new Infisical instance to get it ready for BDD tests +def bootstrap_infisical(context: Context): + bootstrap_result_file = pathlib.Path.cwd() / ".bdd-infisical-bootstrap-result.json" + if bootstrap_result_file.exists(): + logger.info( + "Bootstrap result file exists at %s, loading it now", bootstrap_result_file + ) + return json.loads(bootstrap_result_file.read_text()) + + faker = Faker() + with httpx.Client(base_url=BASE_URL) as client: + resp = client.post( + "/api/v1/admin/signup", + json={ + "email": f"{faker.user_name()}@infisical.com", + "password": faker.password(), + "firstName": faker.first_name(), + "lastName": faker.last_name(), + }, + ) + resp.raise_for_status() + body = resp.json() + org = body["organization"] + user = body["user"] + temp_token = body["token"] + + resp = client.post( + "/api/v3/auth/select-organization", + headers={"Authorization": f"Bearer {temp_token}"}, + json={"organizationId": org["id"]}, + ) + resp.raise_for_status() + body = resp.json() + temp_token = body["token"] + + resp = client.post( + "/api/v1/auth/token", + headers={"Authorization": f"Bearer {temp_token}"}, + json={}, + ) + resp.raise_for_status() + body = resp.json() + auth_token = body["token"] + headers = dict(authorization=f"Bearer {auth_token}") + + project_slug = faker.slug() + resp = client.post( + "/api/v1/projects", + headers=headers, + json={ + "projectName": project_slug, + "projectDescription": faker.paragraph(), + "template": "default", + "type": "cert-manager", + }, + ) + resp.raise_for_status() + body = resp.json() + project = body["project"] + + ca_slug = faker.slug() + resp = client.post( + "/api/v1/pki/ca/internal", + headers=headers, + json={ + "projectId": project["id"], + "name": ca_slug, + "type": "internal", + "status": "active", + "enableDirectIssuance": True, + "configuration": { + "type": "root", + "organization": "Infisican Inc", + "ou": "", + "country": "", + "province": "", + "locality": "", + "commonName": "", + "notAfter": "2035-11-07", + "maxPathLength": -1, + "keyAlgorithm": "RSA_2048", + }, + }, + ) + resp.raise_for_status() + body = resp.json() + ca = body + + cert_template_slug = faker.slug() + resp = client.post( + "/api/v2/certificate-templates", + headers=headers, + json={ + "projectId": project["id"], + "name": cert_template_slug, + "description": "", + "subject": [{"type": "common_name", "allowed": ["*"]}], + "sans": [], + "keyUsages": { + "required": [], + "allowed": [ + "digital_signature", + "non_repudiation", + "key_encipherment", + "data_encipherment", + "key_agreement", + "key_cert_sign", + "crl_sign", + "encipher_only", + "decipher_only", + ], + }, + "extendedKeyUsages": { + "required": [], + "allowed": [ + "client_auth", + "server_auth", + "code_signing", + "email_protection", + "ocsp_signing", + "time_stamping", + ], + }, + "algorithms": { + "signature": [ + "SHA256-RSA", + "SHA512-RSA", + "SHA384-ECDSA", + "SHA384-RSA", + "SHA256-ECDSA", + "SHA512-ECDSA", + ], + "keyAlgorithm": [ + "RSA-2048", + "RSA-4096", + "ECDSA-P384", + "RSA-3072", + "ECDSA-P256", + "ECDSA-P521", + ], + }, + "validity": {"max": "365d"}, + }, + ) + resp.raise_for_status() + body = resp.json() + cert_template = body["certificateTemplate"] + + bootstrap_result = dict( + org=org, + user=user, + project=project, + ca=ca, + cert_template=cert_template, + auth_token=auth_token, + ) + bootstrap_result_file.write_text(json.dumps(bootstrap_result)) + return bootstrap_result def before_all(context: Context): - context.vars = { - "BASE_URL": BASE_URL, - "PROJECT_ID": PROJECT_ID, - "CERT_CA_ID": CERT_CA_ID, - "CERT_TEMPLATE_ID": CERT_TEMPLATE_ID, - "AUTH_TOKEN": AUTH_TOKEN, - } - context.http_client = httpx.Client( - base_url=BASE_URL, # headers={"Authorization": f"Bearer {AUTH_TOKEN}"} - ) + if BOOTSTRAP_INFISICAL: + details = bootstrap_infisical(context) + context.vars = { + "BASE_URL": BASE_URL, + "PROJECT_ID": details["project"]["id"], + "CERT_CA_ID": details["ca"]["id"], + "CERT_TEMPLATE_ID": details["cert_template"]["id"], + "AUTH_TOKEN": details["auth_token"], + } + else: + context.vars = { + "BASE_URL": BASE_URL, + "PROJECT_ID": PROJECT_ID, + "CERT_CA_ID": CERT_CA_ID, + "CERT_TEMPLATE_ID": CERT_TEMPLATE_ID, + "AUTH_TOKEN": AUTH_TOKEN, + } + context.http_client = httpx.Client(base_url=BASE_URL) diff --git a/backend/bdd/features/pki/acme/access-control.feature b/backend/bdd/features/pki/acme/access-control.feature new file mode 100644 index 000000000..6615d00f8 --- /dev/null +++ b/backend/bdd/features/pki/acme/access-control.feature @@ -0,0 +1,273 @@ +Feature: Access Control + + Scenario Outline: Access resources across different account + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account0 + Then I memorize acme_account0.uri with jq "capture("/(?[^/]+)$") | .id" as account0_id + When I create certificate signing request as csr + Then I add names to certificate signing request csr + """ + { + "COMMON_NAME": "localhost" + } + """ + Then I create a RSA private key pair as cert_key + Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + Then I peak and memorize the next nonce as nonce + Then I memorize with jq "" as + When I send a raw ACME request to "" + """ + { + "protected": { + "alg": "RS256", + "nonce": "{nonce}", + "url": "", + "kid": "{acme_account0.uri}" + }, + "payload": {"invalid": "payload"} + } + """ + # With original owner account, the invalid payload is going to trigger other errors instead of 404, this is to make sure + # that our URLs are actually correct + Then the value response.status_code should not be equal to 404 + And I put away current ACME client as client0 + + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" + Then I register a new ACME account with email maidu@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account1 + Then I peak and memorize the next nonce as nonce + When I send a raw ACME request to "" + """ + { + "protected": { + "alg": "RS256", + "nonce": "{nonce}", + "url": "", + "kid": "{acme_account1.uri}" + }, + "raw_payload": "" + } + """ + Then the value response.status_code should be equal to 404 + + Examples: Endpoints + | src_var | jq | dest_var | url | payload | + | order | . | not_used | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account0_id}/orders | | + | order | . | not_used | {order.uri} | | + | order | . | not_used | {order.uri}/finalize | {\"csr\": \"\"} | + | order | . | not_used | {order.uri}/certificate | | + | order | .authorizations[0].uri | auth_uri | {auth_uri} | | + | order | .authorizations[0].body.challenges[0].url | challenge_uri | {challenge_uri} | {} | + + Scenario Outline: Access resources across a different profiles + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account0 + Then I memorize acme_account0.uri with jq "capture("/(?[^/]+)$") | .id" as account0_id + When I create certificate signing request as csr + Then I add names to certificate signing request csr + """ + { + "COMMON_NAME": "localhost" + } + """ + Then I create a RSA private key pair as cert_key + Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + Then I peak and memorize the next nonce as nonce + Then I memorize with jq "" as + When I send a raw ACME request to "" + """ + { + "protected": { + "alg": "RS256", + "nonce": "{nonce}", + "url": "", + "kid": "{acme_account0.uri}" + }, + "payload": {"invalid": "payload"} + } + """ + # With original owner account under their profile, the invalid payload is going to trigger other errors instead of + # 404, this is to make sure that our URLs are actually correct + Then the value response.status_code should not be equal to 404 + And I put away current ACME client as client0 + + Given I make a random slug as profile_slug + Given I use AUTH_TOKEN for authentication + When I send a "POST" request to "/api/v1/pki/certificate-profiles" with JSON payload + """ + { + "projectId": "{PROJECT_ID}", + "slug": "{profile_slug}", + "description": "", + "enrollmentType": "acme", + "caId": "{CERT_CA_ID}", + "certificateTemplateId": "{CERT_TEMPLATE_ID}", + "acmeConfig": {} + } + """ + Then the value response.status_code should be equal to 200 + Then I memorize response with jq ".certificateProfile.id" as profile_id + When I send a "GET" request to "/api/v1/pki/certificate-profiles/{profile_id}/acme/eab-secret/reveal" + Then I memorize response with jq ".eabKid" as eab_kid + And I memorize response with jq ".eabSecret" as eab_secret + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{profile_id}/directory" + Then I register a new ACME account with email maidu@infisical.com and EAB key id "{eab_kid}" with secret "{eab_secret}" as acme_account1 + Then I peak and memorize the next nonce as nonce + Then I memorize with jq "" as + When I send a raw ACME request to "" + """ + { + "protected": { + "alg": "RS256", + "nonce": "{nonce}", + "url": "", + "kid": "{acme_account1.uri}" + }, + "payload": {} + } + """ + Then the value response.status_code should be equal to 404 + + Examples: Endpoints + | src_var | jq | dest_var | url | payload | + | order | . | not_used | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account0_id}/orders | | + | order | . | not_used | {order.uri} | | + | order | . | not_used | {order.uri}/finalize | {\"csr\": \"\"} | + | order | . | not_used | {order.uri}/certificate | | + | order | .authorizations[0].uri | auth_uri | {auth_uri} | | + | order | .authorizations[0].body.challenges[0].url | challenge_uri | {challenge_uri} | {} | + + + Scenario Outline: Access resources across a different profile with the same key pair + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account0 + Then I memorize acme_account0.uri with jq "capture("/(?[^/]+)$") | .id" as account0_id + When I create certificate signing request as csr + Then I add names to certificate signing request csr + """ + { + "COMMON_NAME": "localhost" + } + """ + Then I create a RSA private key pair as cert_key + Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + Then I peak and memorize the next nonce as nonce + Then I memorize with jq "" as + When I send a raw ACME request to "" + """ + { + "protected": { + "alg": "RS256", + "nonce": "{nonce}", + "url": "", + "kid": "{acme_account0.uri}" + }, + "payload": {"invalid": "payload"} + } + """ + # With original owner account under their profile, the invalid payload is going to trigger other errors instead of + # 404, this is to make sure that our URLs are actually correct + Then the value response.status_code should not be equal to 404 + And I put away current ACME client as client0 + + Given I make a random slug as profile_slug + Given I use AUTH_TOKEN for authentication + When I send a "POST" request to "/api/v1/pki/certificate-profiles" with JSON payload + """ + { + "projectId": "{PROJECT_ID}", + "slug": "{profile_slug}", + "description": "", + "enrollmentType": "acme", + "caId": "{CERT_CA_ID}", + "certificateTemplateId": "{CERT_TEMPLATE_ID}", + "acmeConfig": {} + } + """ + Then the value response.status_code should be equal to 200 + Then I memorize response with jq ".certificateProfile.id" as profile_id + When I send a "GET" request to "/api/v1/pki/certificate-profiles/{profile_id}/acme/eab-secret/reveal" + Then I memorize response with jq ".eabKid" as eab_kid + And I memorize response with jq ".eabSecret" as eab_secret + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{profile_id}/directory" with the key pair from client0 + Then I register a new ACME account with email maidu@infisical.com and EAB key id "{eab_kid}" with secret "{eab_secret}" as acme_account1 + Then I peak and memorize the next nonce as nonce + Then I memorize with jq "" as + When I send a raw ACME request to "" + """ + { + "protected": { + "alg": "RS256", + "nonce": "{nonce}", + "url": "", + "kid": "{acme_account1.uri}" + }, + "raw_payload": "" + } + """ + Then the value response.status_code should be equal to 404 + + Examples: Endpoints + | src_var | jq | dest_var | url | payload | + | order | . | not_used | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account0_id}/orders | | + | order | . | not_used | {order.uri} | | + | order | . | not_used | {order.uri}/finalize | {\"csr\": \"\"} | + | order | . | not_used | {order.uri}/certificate | | + | order | .authorizations[0].uri | auth_uri | {auth_uri} | | + | order | .authorizations[0].body.challenges[0].url | challenge_uri | {challenge_uri} | {} | + + + Scenario Outline: URL mismatch + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account + Then I memorize acme_account.uri with jq "capture("/(?[^/]+)$") | .id" as account_id + When I create certificate signing request as csr + Then I add names to certificate signing request csr + """ + { + "COMMON_NAME": "localhost" + } + """ + Then I create a RSA private key pair as cert_key + Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + + Then I peak and memorize the next nonce as nonce + Then I memorize with jq "" as + When I send a raw ACME request to "" + """ + { + "protected": { + "alg": "RS256", + "nonce": "{nonce}", + "url": "", + "kid": "{acme_account.uri}" + }, + "payload": {} + } + """ + Then the value response.status_code should be equal to 400 + Then the value response with jq ".status" should be equal to 400 + Then the value response with jq ".type" should be equal to "urn:ietf:params:acme:error:malformed" + Then the value response with jq ".detail" should be equal to "" + + Examples: Endpoints + | src_var | jq | dest_var | actual_url | bad_url | error_detail | + | order | . | not_used | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders | BAD | Invalid URL in the protected header | + | order | . | not_used | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders | https://evil.com/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders | URL mismatch in the protected header | + | order | . | not_used | {order.uri} | BAD | Invalid URL in the protected header | + | order | . | not_used | {order.uri} | https://example.com/acmes/orders/FOOBAR | URL mismatch in the protected header | + | order | . | not_used | {order.uri}/finalize | BAD | Invalid URL in the protected header | + | order | . | not_used | {order.uri}/finalize | https://example.com/acmes/orders/FOOBAR/finalize | URL mismatch in the protected header | + | order | . | not_used | {order.uri}/certificate | BAD | Invalid URL in the protected header | + | order | . | not_used | {order.uri}/certificate | https://example.com/acmes/orders/FOOBAR/certificate | URL mismatch in the protected header | + | order | .authorizations[0].uri | auth_uri | {auth_uri} | BAD | Invalid URL in the protected header | + | order | .authorizations[0].uri | auth_uri | {auth_uri} | https://example.com/acmes/auths/FOOBAR | URL mismatch in the protected header | + | order | .authorizations[0].body.challenges[0].url | challenge_uri | {challenge_uri} | BAD | Invalid URL in the protected header | + | order | .authorizations[0].body.challenges[0].url | challenge_uri | {challenge_uri} | https://example.com/acmes/challenges/FOOBAR | URL mismatch in the protected header | diff --git a/backend/bdd/features/pki/acme/account.feature b/backend/bdd/features/pki/acme/account.feature index 7e1d67a93..589c5ab24 100644 --- a/backend/bdd/features/pki/acme/account.feature +++ b/backend/bdd/features/pki/acme/account.feature @@ -2,5 +2,53 @@ Feature: Account Scenario: Create a new account Given I have an ACME cert profile as "acme_profile" - When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account + And the value acme_account.uri with jq "." should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/(.+) + + Scenario: Find an existing account + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account + And I memorize acme_account.uri as account_uri + And I find the existing ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account + And the value acme_account.uri should be equal to "{account_uri}" + + Scenario: Create a new account without EAB + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" + Then I register a new ACME account with email fangpen@infisical.com without EAB + And the value error with jq ".type" should be equal to "urn:ietf:params:acme:error:externalAccountRequired" + + Scenario Outline: Scenario: Create a new account with bad EAB credentials + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "" with secret "" as acme_account + And the value error with jq ".type" should be equal to "" + And the value error with jq ".detail" should be equal to "" + + Examples: Bad Credentials + | eab_kid | eab_secret | error_type | error_msg | + | bad | Cg== | urn:ietf:params:acme:error:externalAccountRequired | Invalid external account binding JWS signature | + | {acme_profile.eab_kid} | Cg== | urn:ietf:params:acme:error:externalAccountRequired | Invalid external account binding JWS signature | + | {acme_profile.eab_kid} | YmFkLXNjcmV0Cg== | urn:ietf:params:acme:error:externalAccountRequired | Invalid external account binding JWS signature | + | {acme_profile.eab_kid} | ABC{acme_profile.eab_secret} | urn:ietf:params:acme:error:externalAccountRequired | Invalid external account binding JWS signature | + | bad | {acme_profile.eab_secret} | urn:ietf:params:acme:error:externalAccountRequired | External account binding KID mismatch | + | 4bc7959c-fe2d-4447-ae91-0cd893667af6 | {acme_profile.eab_secret} | urn:ietf:params:acme:error:externalAccountRequired | External account binding KID mismatch | + + Scenario Outline: Scenario: Create a new account with bad EAB url + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" + And I use a different new-account URL "" for EAB signature + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account + And the value error with jq ".type" should be equal to "urn:ietf:params:acme:error:externalAccountRequired" + And the value error with jq ".detail" should be equal to "External account binding URL mismatch" + + Examples: Bad URLs + | url | + | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-account-bad | + | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-account?foo=bar | + | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-account#foobar | + | {BASE_URL}/acme/new-account | + | https://example.com/api/v1/pki/acme/profiles/{acme_profile.id}/new-account-bad | + | bad | diff --git a/backend/bdd/features/pki/acme/auth.feature b/backend/bdd/features/pki/acme/auth.feature index 4605e2eef..46cc9d4e2 100644 --- a/backend/bdd/features/pki/acme/auth.feature +++ b/backend/bdd/features/pki/acme/auth.feature @@ -2,8 +2,7 @@ Feature: Authorization Scenario: Get authorization Given I have an ACME cert profile as "acme_profile" - When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory -# # TODO: make it I have an account already instead? + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account When I create certificate signing request as csr Then I add names to certificate signing request csr @@ -13,11 +12,11 @@ Feature: Authorization } """ Then I create a RSA private key pair as cert_key - Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format - Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order - Then the value order.authorizations[0].uri with jq "." should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/(.+) - Then the value order.authorizations[0].body with jq ".status" should be equal to "pending" - Then the value order.authorizations[0].body with jq ".challenges | map(pick(.type, .status)) | sort_by(.type)" should be equal to json + And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + And the value order.authorizations[0].uri with jq "." should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/(.+) + And the value order.authorizations[0].body with jq ".status" should be equal to "pending" + And the value order.authorizations[0].body with jq ".challenges | map(pick(.type, .status)) | sort_by(.type)" should be equal to json """ [ { @@ -26,8 +25,8 @@ Feature: Authorization } ] """ - Then the value order.authorizations[0].body with jq ".challenges | map(.status) | sort" should be equal to ["pending"] - Then the value order.authorizations[0].body with jq ".identifier" should be equal to json + And the value order.authorizations[0].body with jq ".challenges | map(.status) | sort" should be equal to ["pending"] + And the value order.authorizations[0].body with jq ".identifier" should be equal to json """ { "type": "dns", diff --git a/backend/bdd/features/pki/acme/cert-profile.feature b/backend/bdd/features/pki/acme/cert-profile.feature index 7ce1ecc8c..3c292e8ba 100644 --- a/backend/bdd/features/pki/acme/cert-profile.feature +++ b/backend/bdd/features/pki/acme/cert-profile.feature @@ -2,8 +2,8 @@ Feature: ACME Cert Profile Scenario: Create a cert profile Given I make a random slug as profile_slug - Given I use AUTH_TOKEN for authentication - When I send a POST request to "/api/v1/pki/certificate-profiles" with JSON payload + And I use AUTH_TOKEN for authentication + When I send a "POST" request to "/api/v1/pki/certificate-profiles" with JSON payload """ { "projectId": "{PROJECT_ID}", @@ -16,16 +16,16 @@ Feature: ACME Cert Profile } """ Then the value response.status_code should be equal to 200 - Then the value response with jq ".certificateProfile.id" should be present - Then the value response with jq ".certificateProfile.slug" should be equal to "{profile_slug}" - Then the value response with jq ".certificateProfile.caId" should be equal to "{CERT_CA_ID}" - Then the value response with jq ".certificateProfile.certificateTemplateId" should be equal to "{CERT_TEMPLATE_ID}" - Then the value response with jq ".certificateProfile.enrollmentType" should be equal to "acme" + And the value response with jq ".certificateProfile.id" should be present + And the value response with jq ".certificateProfile.slug" should be equal to "{profile_slug}" + And the value response with jq ".certificateProfile.caId" should be equal to "{CERT_CA_ID}" + And the value response with jq ".certificateProfile.certificateTemplateId" should be equal to "{CERT_TEMPLATE_ID}" + And the value response with jq ".certificateProfile.enrollmentType" should be equal to "acme" Scenario: Reveal EAB secret Given I make a random slug as profile_slug - Given I use AUTH_TOKEN for authentication - When I send a POST request to "/api/v1/pki/certificate-profiles" with JSON payload + And I use AUTH_TOKEN for authentication + When I send a "POST" request to "/api/v1/pki/certificate-profiles" with JSON payload """ { "projectId": "{PROJECT_ID}", @@ -39,11 +39,11 @@ Feature: ACME Cert Profile """ Then the value response.status_code should be equal to 200 And I memorize response with jq ".certificateProfile.id" as profile_id - When I send a GET request to "/api/v1/pki/certificate-profiles/{profile_id}/acme/eab-secret/reveal" + When I send a "GET" request to "/api/v1/pki/certificate-profiles/{profile_id}/acme/eab-secret/reveal" Then the value response.status_code should be equal to 200 - Then the value response with jq ".eabKid" should be equal to "{profile_id}" - Then the value response with jq ".eabSecret" should be present + And the value response with jq ".eabKid" should be equal to "{profile_id}" + And the value response with jq ".eabSecret" should be present And I memorize response with jq ".eabKid" as eab_kid And I memorize response with jq ".eabSecret" as eab_secret - When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{profile_id}/directory + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{profile_id}/directory" Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{eab_kid}" with secret "{eab_secret}" as acme_account diff --git a/backend/bdd/features/pki/acme/challenge.feature b/backend/bdd/features/pki/acme/challenge.feature index ba9970e43..bee46c3fb 100644 --- a/backend/bdd/features/pki/acme/challenge.feature +++ b/backend/bdd/features/pki/acme/challenge.feature @@ -2,8 +2,7 @@ Feature: Challenge Scenario: Validate challenge Given I have an ACME cert profile as "acme_profile" - When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory -# # TODO: make it I have an account already instead? + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account When I create certificate signing request as csr Then I add names to certificate signing request csr @@ -12,12 +11,11 @@ Feature: Challenge "COMMON_NAME": "localhost" } """ - Then I create a RSA private key pair as cert_key - Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format - Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order - Then I select challenge with type http-01 for domain localhost from order at order as challenge - Then I serve challenge response for challenge at localhost - Then I tell ACME server that challenge is ready to be verified - Then I poll and finalize the ACME order order as finalized_order - Then the value finalized_order.body with jq ".status" should be equal to "valid" - # TODO: check the fullchain pem content of the order + And I create a RSA private key pair as cert_key + And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + And I select challenge with type http-01 for domain localhost from order at order as challenge + And I serve challenge response for challenge at localhost + And I tell ACME server that challenge is ready to be verified + And I poll and finalize the ACME order order as finalized_order + And the value finalized_order.body with jq ".status" should be equal to "valid" diff --git a/backend/bdd/features/pki/acme/dicrectory.feature b/backend/bdd/features/pki/acme/dicrectory.feature index 481a3337a..664ff7457 100644 --- a/backend/bdd/features/pki/acme/dicrectory.feature +++ b/backend/bdd/features/pki/acme/dicrectory.feature @@ -2,13 +2,13 @@ Feature: Directory Scenario: Get the directory of ACME service urls Given I have an ACME cert profile as "acme_profile" - When I send a GET request to "/api/v1/pki/acme/profiles/{acme_profile.id}/directory" + When I send a "GET" request to "/api/v1/pki/acme/profiles/{acme_profile.id}/directory" Then the response status code should be "200" - Then the response body should match JSON value - """ - { - "newNonce": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-nonce", - "newAccount": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-account", - "newOrder": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order" - } - """ + And the response body should match JSON value + """ + { + "newNonce": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-nonce", + "newAccount": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-account", + "newOrder": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order" + } + """ diff --git a/backend/bdd/features/pki/acme/nonce.feature b/backend/bdd/features/pki/acme/nonce.feature index 7bbeb3b9d..9a55ae284 100644 --- a/backend/bdd/features/pki/acme/nonce.feature +++ b/backend/bdd/features/pki/acme/nonce.feature @@ -2,6 +2,106 @@ Feature: Nonce Scenario: Generate a new nonce Given I have an ACME cert profile as "acme_profile" - When I send a HEAD request to "/api/v1/pki/acme/profiles/{acme_profile.id}/new-nonce" + When I send a "HEAD" request to "/api/v1/pki/acme/profiles/{acme_profile.id}/new-nonce" Then the response status code should be "200" - Then the response header "Replay-Nonce" should contains non-empty value + And the response header "Replay-Nonce" should contains non-empty value + + Scenario Outline: Send a bad nonce to account endpoints + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account + And I memorize acme_account.uri with jq "capture("/(?[^/]+)$") | .id" as account_id + When I create certificate signing request as csr + Then I add names to certificate signing request csr + """ + { + "COMMON_NAME": "localhost" + } + """ + Then I create a RSA private key pair as cert_key + And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + And I memorize with jq "" as + When I send a raw ACME request to "" + """ + { + "protected": { + "alg": "RS256", + "nonce": "oFvnlFP1wIhRlYS2jTaXbA", + "url": "", + "kid": "{acme_account.uri}" + }, + "payload": {} + } + """ + Then the value response.status_code should be equal to 400 + And the value response with jq ".status" should be equal to 400 + And the value response with jq ".type" should be equal to "urn:ietf:params:acme:error:badNonce" + And the value response with jq ".detail" should be equal to "Invalid nonce" + + Examples: Endpoints + | src_var | jq | dest_var | url | + | order | . | not_used | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders | + | order | . | not_used | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order | + | order | . | not_used | {order.uri} | + | order | . | not_used | {order.uri}/finalize | + | order | . | not_used | {order.uri}/certificate | + | order | .authorizations[0].uri | auth_uri | {auth_uri} | + | order | .authorizations[0].body.challenges[0].url | challenge_uri | {challenge_uri} | + + Scenario Outline: Send the same nonce twice + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account + And I memorize acme_account.uri with jq "capture("/(?[^/]+)$") | .id" as account_id + When I create certificate signing request as csr + Then I add names to certificate signing request csr + """ + { + "COMMON_NAME": "localhost" + } + """ + Then I create a RSA private key pair as cert_key + And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + And I peak and memorize the next nonce as nonce_value + When I send a raw ACME request to "/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders" + """ + { + "protected": { + "alg": "RS256", + "nonce": "{nonce_value}", + "url": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders", + "kid": "{acme_account.uri}" + }, + "payload": {} + } + """ + Then the value response.status_code should be equal to 200 + And I memorize with jq "" as + When I send a raw ACME request to "" + """ + { + "protected": { + "alg": "RS256", + "nonce": "{nonce_value}", + "url": "", + "kid": "{acme_account.uri}" + }, + "payload": {} + } + """ + Then the value response.status_code should be equal to 400 + And the value response with jq ".status" should be equal to 400 + And the value response with jq ".type" should be equal to "urn:ietf:params:acme:error:badNonce" + And the value response with jq ".detail" should be equal to "Invalid nonce" + + Examples: Endpoints + | src_var | jq | dest_var | url | + | order | . | not_used | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders | + | order | . | not_used | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order | + | order | . | not_used | {order.uri} | + | order | . | not_used | {order.uri}/finalize | + | order | . | not_used | {order.uri}/certificate | + | order | .authorizations[0].uri | auth_uri | {auth_uri} | + | order | .authorizations[0].body.challenges[0].url | challenge_uri | {challenge_uri} | diff --git a/backend/bdd/features/pki/acme/order.feature b/backend/bdd/features/pki/acme/order.feature index 046dcda55..19f467f00 100644 --- a/backend/bdd/features/pki/acme/order.feature +++ b/backend/bdd/features/pki/acme/order.feature @@ -2,8 +2,7 @@ Feature: Order Scenario: Create a new order Given I have an ACME cert profile as "acme_profile" - When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory -# # TODO: make it I have an account already instead? + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account When I create certificate signing request as csr Then I add names to certificate signing request csr @@ -13,18 +12,17 @@ Feature: Order } """ Then I create a RSA private key pair as cert_key - Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format - Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order - Then the value order.uri with jq "." should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+) - Then the value order.body with jq ".status" should be equal to "pending" - Then the value order.body with jq ".identifiers" should be equal to [{"type": "dns", "value": "localhost"}] - Then the value order.body with jq ".finalize" should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+)/finalize - Then the value order.body with jq "all(.authorizations[]; startswith("{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/"))" should be equal to true + And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + And the value order.uri with jq "." should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+) + And the value order.body with jq ".status" should be equal to "pending" + And the value order.body with jq ".identifiers" should be equal to [{"type": "dns", "value": "localhost"}] + And the value order.body with jq ".finalize" should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+)/finalize + And the value order.body with jq "all(.authorizations[]; startswith("{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/"))" should be equal to true Scenario: Create a new order with SANs Given I have an ACME cert profile as "acme_profile" - When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory -# # TODO: make it I have an account already instead? + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account When I create certificate signing request as csr Then I add names to certificate signing request csr @@ -33,17 +31,17 @@ Feature: Order "COMMON_NAME": "localhost" } """ - Then I add subject alternative name to certificate signing request csr + And I add subject alternative name to certificate signing request csr """ [ "example.com", "infisical.com" ] """ - Then I create a RSA private key pair as cert_key - Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format - Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order - Then the value order.body with jq ".identifiers | sort_by(.value)" should be equal to json + And I create a RSA private key pair as cert_key + And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + And the value order.body with jq ".identifiers | sort_by(.value)" should be equal to json """ [ {"type": "dns", "value": "example.com"}, @@ -54,8 +52,7 @@ Feature: Order Scenario: Fetch an order Given I have an ACME cert profile as "acme_profile" - When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory -# # TODO: make it I have an account already instead? + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account When I create certificate signing request as csr Then I add names to certificate signing request csr @@ -65,10 +62,81 @@ Feature: Order } """ Then I create a RSA private key pair as cert_key - Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format - Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order - Then I send an ACME post-as-get to order.uri as fetched_order - Then the value fetched_order with jq ".status" should be equal to "pending" - Then the value fetched_order with jq ".identifiers" should be equal to [{"type": "dns", "value": "localhost"}] - Then the value fetched_order with jq ".finalize" should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+)/finalize - Then the value fetched_order with jq "all(.authorizations[]; startswith("{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/"))" should be equal to true + And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + And I send an ACME post-as-get to order.uri as fetched_order + And the value fetched_order with jq ".status" should be equal to "pending" + And the value fetched_order with jq ".identifiers" should be equal to [{"type": "dns", "value": "localhost"}] + And the value fetched_order with jq ".finalize" should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+)/finalize + And the value fetched_order with jq "all(.authorizations[]; startswith("{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/"))" should be equal to true + + Scenario Outline: Create an order with invalid identifier types + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account + And I peak and memorize the next nonce as nonce + When I send a raw ACME request to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order" + """ + { + "protected": { + "alg": "RS256", + "nonce": "{nonce}", + "url": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order", + "kid": "{acme_account.uri}" + }, + "payload": { + "identifiers": [ + { "type": "", "value": "www.example.org" } + ] + } + } + """ + + Then the value response.status_code should be equal to 400 + And the value response with jq ".status" should be equal to 400 + And the value response with jq ".type" should be equal to "urn:ietf:params:acme:error:unsupportedIdentifier" + And the value response with jq ".detail" should be equal to "Only DNS identifiers are supported" + + Examples: Bad Identifier Types + | identifier_type | + | bad | + | ip | + | email | + + Scenario Outline: Create an order with invalid identifier values + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account + And I peak and memorize the next nonce as nonce + When I send a raw ACME request to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order" + """ + { + "protected": { + "alg": "RS256", + "nonce": "{nonce}", + "url": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order", + "kid": "{acme_account.uri}" + }, + "payload": { + "identifiers": [ + { "type": "dns", "value": "" } + ] + } + } + """ + + Then the value response.status_code should be equal to 400 + And the value response with jq ".status" should be equal to 400 + And the value response with jq ".type" should be equal to "urn:ietf:params:acme:error:unsupportedIdentifier" + And the value response with jq ".detail" should be equal to "Invalid DNS identifier" + + Examples: Bad Identifier Vluaes + | identifier_value | + | 127.0.0.1 | + | 192.168.123.111 | + | 169.254.169.254 | + | ../../etc/passwd | + | !@#$ | + | ! | + | https://evil.com | + diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index d0fa627cd..d9004e0ba 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -1,9 +1,10 @@ import json import logging -import os import re import threading +import urllib.parse +import acme.client import httpx import jq import requests @@ -12,12 +13,14 @@ from faker import Faker from acme import client from acme import messages from acme import standalone +from acme.jws import Signature from behave.runner import Context from behave import given from behave import when from behave import then from josepy.jwk import JWKRSA from josepy import JSONObjectWithFields +from josepy import json_util from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa from cryptography import x509 @@ -134,12 +137,39 @@ def step_impl(context: Context, faker_type: str, var_name: str): @given('I have an ACME cert profile as "{profile_var}"') def step_impl(context: Context, profile_var: str): - # TODO: Fixed value for now, just to make test much easier, - # we should call infisical API to create such profile instead - # in the future - profile_id = os.getenv("PROFILE_ID") - kid = profile_id - secret = os.getenv("EAB_SECRET") + profile_id = context.vars.get("PROFILE_ID") + secret = context.vars.get("EAB_SECRET") + if profile_id is not None and secret is not None: + kid = profile_id + else: + profile_slug = faker.slug() + jwt_token = context.vars["AUTH_TOKEN"] + response = context.http_client.post( + "/api/v1/pki/certificate-profiles", + headers=dict(authorization="Bearer {}".format(jwt_token)), + json={ + "projectId": context.vars["PROJECT_ID"], + "slug": profile_slug, + "description": "ACME Profile created by BDD test", + "enrollmentType": "acme", + "caId": context.vars["CERT_CA_ID"], + "certificateTemplateId": context.vars["CERT_TEMPLATE_ID"], + "acmeConfig": {}, + }, + ) + response.raise_for_status() + resp_json = response.json() + profile_id = resp_json["certificateProfile"]["id"] + kid = profile_id + + response = context.http_client.get( + f"/api/v1/pki/certificate-profiles/{profile_id}/acme/eab-secret/reveal", + headers=dict(authorization="Bearer {}".format(jwt_token)), + ) + response.raise_for_status() + resp_json = response.json() + secret = resp_json["eabSecret"] + context.vars[profile_var] = AcmeProfile( profile_id, eab_kid=kid, @@ -152,7 +182,7 @@ def step_impl(context: Context, token_var: str): context.auth_token = eval_var(context, token_var) -@when('I send a {method} request to "{url}"') +@when('I send a "{method}" request to "{url}"') def step_impl(context: Context, method: str, url: str): logger.debug("Sending %s request to %s", method, url) response = context.http_client.request( @@ -166,7 +196,7 @@ def step_impl(context: Context, method: str, url: str): pass -@when('I send a {method} request to "{url}" with JSON payload') +@when('I send a "{method}" request to "{url}" with JSON payload') def step_impl(context: Context, method: str, url: str): json_payload = json.loads(context.text) json_payload = replace_vars(json_payload, context.vars) @@ -187,23 +217,34 @@ def step_impl(context: Context, method: str, url: str): logger.debug("Response JSON payload: %r", response.json()) -@when("I have an ACME client connecting to {url}") -def step_impl(context: Context, url: str): - private_key = rsa.generate_private_key( - public_exponent=ACC_KEY_PUBLIC_EXPONENT, key_size=ACC_KEY_BITS - ) - pem_bytes = private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption(), - ) - acc_jwk = JWKRSA.load(pem_bytes) +def create_acme_client(context: Context, url: str, acc_jwk: JWKRSA | None = None): + if acc_jwk is None: + private_key = rsa.generate_private_key( + public_exponent=ACC_KEY_PUBLIC_EXPONENT, key_size=ACC_KEY_BITS + ) + pem_bytes = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + acc_jwk = JWKRSA.load(pem_bytes) net = client.ClientNetwork(acc_jwk) directory_url = url.format(**context.vars) directory = client.ClientV2.get_directory(directory_url, net) context.acme_client = client.ClientV2(directory, net=net) +@when('I have an ACME client connecting to "{url}"') +def step_impl(context: Context, url: str): + create_acme_client(context, url) + + +@when('I have an ACME client connecting to "{url}" with the key pair from {client_var}') +def step_impl(context: Context, url: str, client_var: str): + another_client = eval_var(context, client_var, as_json=False) + create_acme_client(context, url, acc_jwk=another_client.net.key) + + @then('the response status code should be "{expected_status_code:d}"') def step_impl(context: Context, expected_status_code: int): assert context.vars["response"].status_code == expected_status_code, ( @@ -228,24 +269,122 @@ def step_impl(context: Context): assert payload == replaced, f"{payload} != {replaced}" -@then( - 'I register a new ACME account with email {email} and EAB key id "{kid}" with secret "{secret}" as {account_var}' -) -def step_impl(context: Context, email: str, kid: str, secret: str, account_var: str): +@when('I use a different new-account URL "{url}" for EAB signature') +def step_impl(context: Context, url: str): + context.alt_eab_url = replace_vars(url, context.vars) + + +def register_account_with_eab( + context: Context, + email: str, + kid: str, + secret: str, + account_var: str, + only_return_existing: bool = False, +): acme_client = context.acme_client account_public_key = acme_client.net.key.public_key() + if hasattr(context, "alt_eab_url"): + eab_directory = messages.Directory.from_json( + {"newAccount": context.alt_eab_url} + ) + else: + eab_directory = acme_client.directory eab = messages.ExternalAccountBinding.from_data( account_public_key=account_public_key, kid=replace_vars(kid, context.vars), hmac_key=replace_vars(secret, context.vars), - directory=acme_client.directory, + directory=eab_directory, hmac_alg="HS256", ) registration = messages.NewRegistration.from_data( email=email, external_account_binding=eab, + only_return_existing=only_return_existing, ) - context.vars[account_var] = acme_client.new_account(registration) + try: + context.vars[account_var] = acme_client.new_account(registration) + except Exception as exp: + context.vars["error"] = exp + + +@then( + 'I register a new ACME account with email {email} and EAB key id "{kid}" with secret "{secret}" as {account_var}' +) +def step_impl(context: Context, email: str, kid: str, secret: str, account_var: str): + register_account_with_eab( + context=context, email=email, kid=kid, secret=secret, account_var=account_var + ) + + +@then( + 'I find the existing ACME account with email {email} and EAB key id "{kid}" with secret "{secret}" as {account_var}' +) +def step_impl(context: Context, email: str, kid: str, secret: str, account_var: str): + register_account_with_eab( + context=context, + email=email, + kid=kid, + secret=secret, + account_var=account_var, + only_return_existing=True, + ) + + +@then("I register a new ACME account with email {email} without EAB") +def step_impl(context: Context, email: str): + acme_client = context.acme_client + registration = messages.NewRegistration.from_data( + email=email, + ) + try: + context.vars["error"] = acme_client.new_account(registration) + except Exception as exp: + context.vars["error"] = exp + + +def send_raw_acme_req(context: Context, url: str): + acme_client = context.acme_client + content = json.loads(context.text) + protected = replace_vars(content["protected"], context.vars) + alg = acme_client.net.alg + if "raw_payload" in content: + encoded_payload = content["raw_payload"].encode("utf-8") + elif "payload" in content: + payload = ( + replace_vars(content["payload"], context.vars) + if "payload" in content + else None + ) + encoded_payload = json.dumps(payload).encode() if payload is not None else b"" + else: + encoded_payload = b"" + protected_headers = json.dumps(protected) + signature = alg.sign( + key=acme_client.net.key.key, + msg=Signature._msg(protected_headers, encoded_payload), + ) + jws = json.dumps( + { + "protected": json_util.encode_b64jose(protected_headers.encode()), + "payload": json_util.encode_b64jose(encoded_payload), + "signature": json_util.encode_b64jose(signature), + } + ) + base_url = context.vars["BASE_URL"] + actual_url = urllib.parse.urljoin(base_url, replace_vars(url, context.vars)) + response = acme_client.net._send_request( + "POST", + actual_url, + data=jws, + headers={"Content-Type": acme.client.ClientNetwork.JOSE_CONTENT_TYPE}, + ) + context.vars["response"] = response + + +@when('I send a raw ACME request to "{url}"') +def step_impl(context: Context, url: str): + send_raw_acme_req(context, url) @then( @@ -384,10 +523,17 @@ def step_impl(context: Context, var_path: str): @then("the value {var_path} should be equal to {expected}") def step_impl(context: Context, var_path: str, expected: str): value = eval_var(context, var_path) - expected_value = json.loads(expected) + expected_value = replace_vars(json.loads(expected), context.vars) assert value == expected_value, f"{value!r} does not match {expected_value!r}" +@then("the value {var_path} should not be equal to {expected}") +def step_impl(context: Context, var_path: str, expected: str): + value = eval_var(context, var_path) + expected_value = replace_vars(json.loads(expected), context.vars) + assert value != expected_value, f"{value!r} does match {expected_value!r}" + + @then('I memorize {var_path} with jq "{jq_query}" as {var_name}') def step_impl(context: Context, var_path: str, jq_query, var_name: str): _, value = apply_value_with_jq( @@ -398,6 +544,19 @@ def step_impl(context: Context, var_path: str, jq_query, var_name: str): context.vars[var_name] = value +@then("I peak and memorize the next nonce as {var_name}") +def step_impl(context: Context, var_name: str): + acme_client = context.acme_client + context.vars[var_name] = json_util.encode_b64jose(list(acme_client.net._nonces)[0]) + + +@then("I put away current ACME client as {var_name}") +def step_impl(context: Context, var_name: str): + acme_client = context.acme_client + del context.acme_client + context.vars[var_name] = acme_client + + @then("I memorize {var_path} as {var_name}") def step_impl(context: Context, var_path: str, var_name: str): value = eval_var(context, var_path) diff --git a/backend/package.json b/backend/package.json index baa81e65b..7a5efcb78 100644 --- a/backend/package.json +++ b/backend/package.json @@ -44,6 +44,7 @@ "test:e2e": "vitest run -c vitest.e2e.config.mts --bail=1", "test:e2e-watch": "vitest -c vitest.e2e.config.mts --bail=1", "test:e2e-coverage": "vitest run --coverage -c vitest.e2e.config.mts", + "test:bdd": "cd bdd && uv run behave", "generate:component": "tsx ./scripts/create-backend-file.ts", "generate:schema": "tsx ./scripts/generate-schema-types.ts && eslint --fix --ext ts ./src/db/schemas", "auditlog-migration:latest": "node ./dist/db/rename-migrations-to-mjs.mjs && knex --knexfile ./dist/db/auditlog-knexfile.mjs --client pg migrate:latest", diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts index 627537c44..d58790039 100644 --- a/backend/src/ee/routes/v1/pki-acme-router.ts +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -261,7 +261,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { req }); if (payload !== "") { - throw new AcmeMalformedError({ detail: "Payload should be empty" }); + throw new AcmeMalformedError({ message: "Payload should be empty" }); } return sendAcmeResponse( res, @@ -373,7 +373,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { req }); if (payload !== "") { - throw new AcmeMalformedError({ detail: "Payload should be empty" }); + throw new AcmeMalformedError({ message: "Payload should be empty" }); } res.type("application/pem-certificate-chain"); return sendAcmeResponse( @@ -407,7 +407,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { handler: async (req, res) => { const { profileId, accountId, payload } = await validateExistingAccount({ req }); if (payload !== "") { - throw new AcmeMalformedError({ detail: "Payload should be empty" }); + throw new AcmeMalformedError({ message: "Payload should be empty" }); } return sendAcmeResponse( res, diff --git a/backend/src/ee/services/pki-acme/pki-acme-errors.ts b/backend/src/ee/services/pki-acme/pki-acme-errors.ts index febce5e81..837dec8be 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-errors.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-errors.ts @@ -35,7 +35,7 @@ export enum AcmeErrorType { export interface IAcmeError { type: AcmeErrorType; - detail: string; + message: string; status: number; subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>; } @@ -43,7 +43,7 @@ export interface IAcmeError { export class AcmeError extends Error implements IAcmeError { type: AcmeErrorType; - detail: string; + message: string; status: number; @@ -53,22 +53,20 @@ export class AcmeError extends Error implements IAcmeError { constructor({ type, - detail, + message, status, subproblems, - error, - message + error }: { type: AcmeErrorType; - detail: string; + message: string; status: number; subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>; error?: unknown; - message?: string; }) { - super(message || detail); + super(message); this.type = type; - this.detail = detail; + this.message = message; this.status = status; this.subproblems = subproblems; this.error = error; @@ -78,7 +76,7 @@ export class AcmeError extends Error implements IAcmeError { toAcmeResponse(): IAcmeError { return { type: this.type, - detail: this.detail, + message: this.message, status: this.status, subproblems: this.subproblems }; @@ -90,20 +88,17 @@ export class AcmeError extends Error implements IAcmeError { */ export class AcmeMalformedError extends AcmeError { constructor({ - detail = "The request message was malformed", - error, - message + message = "The request message was malformed", + error }: { - detail?: string; - error?: unknown; message?: string; + error?: unknown; } = {}) { super({ type: AcmeErrorType.Malformed, - detail, + message, status: 400, - error, - message + error }); this.name = "AcmeMalformedError"; } @@ -114,20 +109,17 @@ export class AcmeMalformedError extends AcmeError { */ export class AcmeUnauthorizedError extends AcmeError { constructor({ - detail = "The client lacks sufficient authorization", - error, - message + message = "The client lacks sufficient authorization", + error }: { - detail?: string; - error?: unknown; message?: string; + error?: unknown; } = {}) { super({ type: AcmeErrorType.Unauthorized, - detail, + message, status: 403, - error, - message + error }); this.name = "AcmeUnauthorizedError"; } @@ -139,20 +131,17 @@ export class AcmeUnauthorizedError extends AcmeError { */ export class AcmeAccountDoesNotExistError extends AcmeError { constructor({ - detail = "The request specified an account that does not exist", - error, - message + message = "The request specified an account that does not exist", + error }: { - detail?: string; - error?: unknown; message?: string; + error?: unknown; } = {}) { super({ type: AcmeErrorType.AccountDoesNotExist, - detail, - status: 400, - error, - message + message, + status: 404, + error }); this.name = "AcmeAccountDoesNotExistError"; } @@ -163,20 +152,17 @@ export class AcmeAccountDoesNotExistError extends AcmeError { */ export class AcmeBadNonceError extends AcmeError { constructor({ - detail = "The client sent an unacceptable anti-replay nonce", - error, - message + message = "The client sent an unacceptable anti-replay nonce", + error }: { - detail?: string; - error?: unknown; message?: string; + error?: unknown; } = {}) { super({ type: AcmeErrorType.BadNonce, - detail, + message, status: 400, - error, - message + error }); this.name = "AcmeBadNonceError"; } @@ -187,20 +173,17 @@ export class AcmeBadNonceError extends AcmeError { */ export class AcmeBadSignatureAlgorithmError extends AcmeError { constructor({ - detail = "The signature algorithm is invalid", - error, - message + message = "The signature algorithm is invalid", + error }: { - detail?: string; - error?: unknown; message?: string; + error?: unknown; } = {}) { super({ type: AcmeErrorType.BadSignatureAlgorithm, - detail, + message, status: 401, - error, - message + error }); this.name = "AcmeBadSignatureAlgorithmError"; } @@ -211,20 +194,17 @@ export class AcmeBadSignatureAlgorithmError extends AcmeError { */ export class AcmeBadPublicKeyError extends AcmeError { constructor({ - detail = "The public key is not acceptable", - error, - message + message = "The public key is not acceptable", + error }: { - detail?: string; - error?: unknown; message?: string; + error?: unknown; } = {}) { super({ type: AcmeErrorType.BadPublicKey, - detail, + message, status: 400, - error, - message + error }); this.name = "AcmeBadPublicKeyError"; } @@ -235,20 +215,17 @@ export class AcmeBadPublicKeyError extends AcmeError { */ export class AcmeBadCsrError extends AcmeError { constructor({ - detail = "The CSR is unacceptable", - error, - message + message = "The CSR is unacceptable", + error }: { - detail?: string; - error?: unknown; message?: string; + error?: unknown; } = {}) { super({ type: AcmeErrorType.BadCsr, - detail, + message, status: 400, - error, - message + error }); this.name = "AcmeBadCsrError"; } @@ -260,20 +237,17 @@ export class AcmeBadCsrError extends AcmeError { */ export class AcmeBadRevocationReasonError extends AcmeError { constructor({ - detail = "The revocation reason provided is not allowed", - error, - message + message = "The revocation reason provided is not allowed", + error }: { - detail?: string; - error?: unknown; message?: string; + error?: unknown; } = {}) { super({ type: AcmeErrorType.BadRevocationReason, - detail, + message, status: 400, - error, - message + error }); this.name = "AcmeBadRevocationReasonError"; } @@ -284,20 +258,17 @@ export class AcmeBadRevocationReasonError extends AcmeError { */ export class AcmeRateLimitedError extends AcmeError { constructor({ - detail = "The client has exceeded a rate limit", - error, - message + message = "The client has exceeded a rate limit", + error }: { - detail?: string; - error?: unknown; message?: string; + error?: unknown; } = {}) { super({ type: AcmeErrorType.RateLimited, - detail, + message, status: 429, - error, - message + error }); this.name = "AcmeRateLimitedError"; } @@ -309,23 +280,20 @@ export class AcmeRateLimitedError extends AcmeError { */ export class AcmeRejectedIdentifierError extends AcmeError { constructor({ - detail = "The server will not issue certificates for the identifier", + message = "The server will not issue certificates for the identifier", subproblems, - error, - message + error }: { - detail?: string; + message?: string; subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>; error?: unknown; - message?: string; } = {}) { super({ type: AcmeErrorType.RejectedIdentifier, - detail, + message, status: 400, subproblems, - error, - message + error }); this.name = "AcmeRejectedIdentifierError"; } @@ -336,20 +304,17 @@ export class AcmeRejectedIdentifierError extends AcmeError { */ export class AcmeServerInternalError extends AcmeError { constructor({ - detail = "An internal error occurred", - error, - message + message = "An internal error occurred", + error }: { - detail?: string; - error?: unknown; message?: string; + error?: unknown; } = {}) { super({ type: AcmeErrorType.ServerInternal, - detail, + message, status: 500, - error, - message + error }); this.name = "AcmeServerInternalError"; } @@ -360,20 +325,17 @@ export class AcmeServerInternalError extends AcmeError { */ export class AcmeUnsupportedContactError extends AcmeError { constructor({ - detail = "A contact URL is of an unsupported type", - error, - message + message = "A contact URL is of an unsupported type", + error }: { - detail?: string; - error?: unknown; message?: string; + error?: unknown; } = {}) { super({ type: AcmeErrorType.UnsupportedContact, - detail, + message, status: 400, - error, - message + error }); this.name = "AcmeUnsupportedContactError"; } @@ -385,20 +347,17 @@ export class AcmeUnsupportedContactError extends AcmeError { */ export class AcmeUnsupportedIdentifierError extends AcmeError { constructor({ - detail = "An identifier is of an unsupported type", - error, - message + message = "An identifier is of an unsupported type", + error }: { - detail?: string; - error?: unknown; message?: string; + error?: unknown; } = {}) { super({ type: AcmeErrorType.UnsupportedIdentifier, - detail, + message, status: 400, - error, - message + error }); this.name = "AcmeUnsupportedIdentifierError"; } @@ -412,22 +371,19 @@ export class AcmeUserActionRequiredError extends AcmeError { instance?: string; constructor({ - detail = "Visit the instance URL and take actions specified there", + message = "Visit the instance URL and take actions specified there", instance, - error, - message + error }: { - detail?: string; + message?: string; instance?: string; error?: unknown; - message?: string; } = {}) { super({ type: AcmeErrorType.UserActionRequired, - detail, + message, status: 403, - error, - message + error }); this.instance = instance; this.name = "AcmeUserActionRequiredError"; @@ -446,20 +402,17 @@ export class AcmeUserActionRequiredError extends AcmeError { */ export class AcmeIncorrectResponseError extends AcmeError { constructor({ - detail = "The response is incorrect", - error, - message + message = "The response is incorrect", + error }: { - detail?: string; - error?: unknown; message?: string; + error?: unknown; } = {}) { super({ type: AcmeErrorType.IncorrectResponse, - detail, + message, status: 400, - error, - message + error }); this.name = "AcmeIncorrectResponseError"; } @@ -470,20 +423,17 @@ export class AcmeIncorrectResponseError extends AcmeError { */ export class AcmeConnectionError extends AcmeError { constructor({ - detail = "A connection error occurred", - error, - message + message = "A connection error occurred", + error }: { - detail?: string; - error?: unknown; message?: string; + error?: unknown; } = {}) { super({ type: AcmeErrorType.Connection, - detail, + message, status: 400, - error, - message + error }); this.name = "AcmeConnectionError"; } @@ -491,20 +441,17 @@ export class AcmeConnectionError extends AcmeError { export class AcmeDnsFailureError extends AcmeError { constructor({ - detail = "Hostname could not be resolved (DNS failure)", - error, - message + message = "Hostname could not be resolved (DNS failure)", + error }: { - detail?: string; - error?: unknown; message?: string; + error?: unknown; } = {}) { super({ type: AcmeErrorType.DNS, - detail, + message, status: 400, - error, - message + error }); this.name = "AcmeDnsFailureError"; } @@ -512,20 +459,17 @@ export class AcmeDnsFailureError extends AcmeError { export class AcmeOrderNotReadyError extends AcmeError { constructor({ - detail = "The order is not ready", - error, - message + message = "The order is not ready", + error }: { - detail?: string; - error?: unknown; message?: string; + error?: unknown; } = {}) { super({ type: AcmeErrorType.OrderNotReady, - detail, + message, status: 403, - error, - message + error }); this.name = "AcmeOrderNotReadyError"; } @@ -533,20 +477,17 @@ export class AcmeOrderNotReadyError extends AcmeError { export class AcmeBadCSRError extends AcmeError { constructor({ - detail = "The CSR is unacceptable", - error, - message + message = "The CSR is unacceptable", + error }: { - detail?: string; - error?: unknown; message?: string; + error?: unknown; } = {}) { super({ type: AcmeErrorType.BadCsr, - detail, + message, status: 400, - error, - message + error }); this.name = "AcmeBadCSRError"; } @@ -554,20 +495,17 @@ export class AcmeBadCSRError extends AcmeError { export class AcmeExternalAccountRequiredError extends AcmeError { constructor({ - detail = "External account binding is required", - error, - message + message = "External account binding is required", + error }: { - detail?: string; - error?: unknown; message?: string; + error?: unknown; } = {}) { super({ type: AcmeErrorType.ExternalAccountRequired, - detail, + message, status: 400, - error, - message + error }); this.name = "AcmeExternalAccountRequiredError"; } diff --git a/backend/src/ee/services/pki-acme/pki-acme-fns.ts b/backend/src/ee/services/pki-acme/pki-acme-fns.ts index 828e0801b..cc7ddb9b1 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-fns.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-fns.ts @@ -1,8 +1,7 @@ -import { z } from "zod"; - import { getConfig } from "@app/lib/config/env"; - -import { AcmeMalformedError } from "./pki-acme-errors"; +import RE2 from "re2"; +import { z } from "zod"; +import { AcmeAccountDoesNotExistError } from "./pki-acme-errors"; export const buildUrl = (profileId: string, path: string): string => { const appCfg = getConfig(); @@ -13,7 +12,14 @@ export const buildUrl = (profileId: string, path: string): string => { export const extractAccountIdFromKid = (kid: string, profileId: string): string => { const kidPrefix = buildUrl(profileId, "/accounts/"); if (!kid.startsWith(kidPrefix)) { - throw new AcmeMalformedError({ detail: "KID must start with the profile account URL" }); + throw new AcmeAccountDoesNotExistError({ message: "KID must start with the profile account URL" }); } return z.string().uuid().parse(kid.slice(kidPrefix.length)); }; + +export const validateDnsIdentifier = (identifier: string): boolean => { + // DNS label pattern: 1-63 chars, alphanumeric or hyphen, but not starting or ending with hyphen + const labelPattern = new RE2(/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/); + const labels = identifier.split("."); + return labels.every((label) => label.length >= 1 && label.length <= 63 && labelPattern.test(label)); +}; diff --git a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts index 4c7d6c3c1..58ca7e833 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts @@ -1,4 +1,3 @@ -import RE2 from "re2"; import { z } from "zod"; export enum AcmeIdentifierType { @@ -88,13 +87,8 @@ export const CreateAcmeAccountResponseSchema = z.object({ export const CreateAcmeOrderBodySchema = z.object({ identifiers: z.array( z.object({ - type: z.enum(Object.values(AcmeIdentifierType) as [string, ...string[]]), - value: z.string().refine((val) => { - // DNS label pattern: 1-63 chars, alphanumeric or hyphen, but not starting or ending with hyphen - const labelPattern = new RE2(/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/); - const labels = val.split("."); - return labels.every((label) => label.length >= 1 && label.length <= 63 && labelPattern.test(label)); - }, "Invalid DNS identifier") + type: z.string(), + value: z.string() }) ), notBefore: z.string().optional(), diff --git a/backend/src/ee/services/pki-acme/pki-acme-service.ts b/backend/src/ee/services/pki-acme/pki-acme-service.ts index 1e7123353..fa0d0ff66 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -13,21 +13,22 @@ import { TPkiAcmeAccounts } from "@app/db/schemas/pki-acme-accounts"; import { TPkiAcmeAuths } from "@app/db/schemas/pki-acme-auths"; import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; import { crypto } from "@app/lib/crypto/cryptography"; -import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { isPrivateIp } from "@app/lib/ip/ipRange"; import { logger } from "@app/lib/logger"; import { ActorType } from "@app/services/auth/auth-type"; -import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; import { EnrollmentType, TCertificateProfileWithConfigs } from "@app/services/certificate-profile/certificate-profile-types"; import { TCertificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service"; +import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; +import { getConfig } from "@app/lib/config/env"; import { TPkiAcmeAccountDALFactory } from "./pki-acme-account-dal"; import { TPkiAcmeAuthDALFactory } from "./pki-acme-auth-dal"; import { TPkiAcmeChallengeDALFactory } from "./pki-acme-challenge-dal"; @@ -41,10 +42,9 @@ import { AcmeMalformedError, AcmeOrderNotReadyError, AcmeServerInternalError, - AcmeUnauthorizedError, AcmeUnsupportedIdentifierError } from "./pki-acme-errors"; -import { buildUrl, extractAccountIdFromKid } from "./pki-acme-fns"; +import { buildUrl, extractAccountIdFromKid, validateDnsIdentifier } from "./pki-acme-fns"; import { TPkiAcmeOrderAuthDALFactory } from "./pki-acme-order-auth-dal"; import { TPkiAcmeOrderDALFactory } from "./pki-acme-order-dal"; import { @@ -148,7 +148,7 @@ export const pkiAcmeServiceFactory = ({ try { result = await flattenedVerify(rawJwsPayload, async (protectedHeader: JWSHeaderParameters | undefined) => { if (protectedHeader === undefined) { - throw new AcmeMalformedError({ detail: "Protected header is required" }); + throw new AcmeMalformedError({ message: "Protected header is required" }); } const jwk = await getJWK(protectedHeader); const key = await importJWK(jwk, protectedHeader.alg); @@ -159,28 +159,35 @@ export const pkiAcmeServiceFactory = ({ throw error; } if (error instanceof ZodError) { - throw new AcmeMalformedError({ detail: `Invalid JWS payload: ${error.message}` }); + throw new AcmeMalformedError({ message: `Invalid JWS payload: ${error.message}` }); } if (error instanceof errors.JWSSignatureVerificationFailed) { - throw new AcmeBadPublicKeyError({ detail: "Invalid JWS payload" }); + throw new AcmeBadPublicKeyError({ message: "Invalid JWS payload" }); } logger.error(error, "Unexpected error while verifying JWS payload"); - throw new AcmeServerInternalError({ detail: "Failed to verify JWS payload" }); + throw new AcmeMalformedError({ message: "Failed to verify JWS payload" }); } const { protectedHeader: rawProtectedHeader, payload: rawPayload } = result; try { const protectedHeader = ProtectedHeaderSchema.parse(rawProtectedHeader); + const parsedUrl = (() => { + try { + return new URL(protectedHeader.url); + } catch (error) { + throw new AcmeMalformedError({ message: "Invalid URL in the protected header" }); + } + })(); // Validate the URL - if (new URL(protectedHeader.url).href !== url.href) { - throw new AcmeUnauthorizedError({ detail: "URL mismatch in the protected header" }); + if (parsedUrl.href !== url.href) { + throw new AcmeMalformedError({ message: "URL mismatch in the protected header" }); } // Consume the nonce if (!protectedHeader.nonce) { - throw new AcmeMalformedError({ detail: "Nonce is required in the protected header" }); + throw new AcmeMalformedError({ message: "Nonce is required in the protected header" }); } const deleted = await keyStore.deleteItem(KeyStorePrefixes.PkiAcmeNonce(protectedHeader.nonce)); if (deleted !== 1) { - throw new AcmeBadNonceError({ detail: "Invalid nonce" }); + throw new AcmeBadNonceError({ message: "Invalid nonce" }); } // Parse the payload @@ -196,10 +203,10 @@ export const pkiAcmeServiceFactory = ({ throw error; } if (error instanceof ZodError) { - throw new AcmeMalformedError({ detail: `Invalid JWS payload: ${error.message}` }); + throw new AcmeMalformedError({ message: `Invalid JWS payload: ${error.message}` }); } logger.error(error, "Unexpected error while parsing JWS payload"); - throw new AcmeServerInternalError({ detail: "Failed to verify JWS payload" }); + throw new AcmeMalformedError({ message: "Failed to verify JWS payload" }); } }; @@ -215,7 +222,7 @@ export const pkiAcmeServiceFactory = ({ rawJwsPayload, getJWK: async (protectedHeader) => { if (!protectedHeader.jwk) { - throw new AcmeMalformedError({ detail: "JWK is required in the protected header" }); + throw new AcmeMalformedError({ message: "JWK is required in the protected header" }); } return protectedHeader.jwk as unknown as JsonWebKey; }, @@ -246,18 +253,18 @@ export const pkiAcmeServiceFactory = ({ rawJwsPayload, getJWK: async (protectedHeader) => { if (!protectedHeader.kid) { - throw new AcmeMalformedError({ detail: "KID is required in the protected header" }); + throw new AcmeMalformedError({ message: "KID is required in the protected header" }); } const accountId = extractAccountIdFromKid(protectedHeader.kid, profileId); if (expectedAccountId && accountId !== expectedAccountId) { - throw new NotFoundError({ message: "ACME resource not found" }); + throw new AcmeAccountDoesNotExistError({ message: "ACME resource not found" }); } const account = await acmeAccountDAL.findByProjectIdAndAccountId(profile.id, accountId); if (!account) { throw new AcmeAccountDoesNotExistError({ message: "ACME account not found" }); } if (account.alg !== protectedHeader.alg) { - throw new AcmeMalformedError({ detail: "ACME account algorithm mismatch" }); + throw new AcmeMalformedError({ message: "ACME account algorithm mismatch" }); } return account.publicKey as JsonWebKey; }, @@ -344,7 +351,7 @@ export const pkiAcmeServiceFactory = ({ }): Promise> => { const profile = await validateAcmeProfile(profileId); if (!externalAccountBinding) { - throw new AcmeExternalAccountRequiredError({ detail: "External account binding is required" }); + throw new AcmeExternalAccountRequiredError({ message: "External account binding is required" }); } const publicKeyThumbprint = await calculateJwkThumbprint(jwk, "sha256"); @@ -363,26 +370,28 @@ export const pkiAcmeServiceFactory = ({ return { eabPayload: result.payload, eabProtectedHeader: result.protectedHeader }; } catch (error) { if (error instanceof errors.JWSSignatureVerificationFailed) { - throw new AcmeMalformedError({ detail: "Invalid external account binding JWS signature" }); + throw new AcmeExternalAccountRequiredError({ message: "Invalid external account binding JWS signature" }); } logger.error(error, "Unexpected error while verifying EAB JWS signature"); - throw new AcmeServerInternalError({ detail: "Failed to verify EAB JWS signature" }); + throw new AcmeServerInternalError({ message: "Failed to verify EAB JWS signature" }); } })(); const { alg: eabAlg, kid: eabKid } = eabProtectedHeader!; if (!["HS256", "HS384", "HS512"].includes(eabAlg!)) { - throw new AcmeMalformedError({ detail: "Invalid algorithm for external account binding JWS payload" }); + throw new AcmeExternalAccountRequiredError({ + message: "Invalid algorithm for external account binding JWS payload" + }); } // Make sure the KID in the EAB payload matches the profile ID if (eabKid !== profile.id) { - throw new UnauthorizedError({ message: "External account binding KID mismatch" }); + throw new AcmeExternalAccountRequiredError({ message: "External account binding KID mismatch" }); } // Make sure the URL matches the expected URL const url = eabProtectedHeader!.url!; if (url !== buildUrl(profile.id, "/new-account")) { - throw new UnauthorizedError({ message: "External account binding URL mismatch" }); + throw new AcmeExternalAccountRequiredError({ message: "External account binding URL mismatch" }); } // Make sure the JWK in the EAB payload matches the one provided in the outer JWS payload @@ -481,6 +490,21 @@ export const pkiAcmeServiceFactory = ({ // TODO: check the identifiers and see if are they even allowed for this profile. // if not, we may be able to reject it early with an unsupportedIdentifier error. + // TODO: ideally, we should return an error with subproblems if we have multiple unsupported identifiers + if (payload.identifiers.some((identifier) => identifier.type !== AcmeIdentifierType.DNS)) { + throw new AcmeUnsupportedIdentifierError({ message: "Only DNS identifiers are supported" }); + } + if ( + payload.identifiers.some( + (identifier) => + !validateDnsIdentifier(identifier.value) || + isPrivateIp(identifier.value) || + (!getConfig().isDevelopmentMode && identifier.value.toLowerCase() === "localhost") + ) + ) { + throw new AcmeUnsupportedIdentifierError({ message: "Invalid DNS identifier" }); + } + const order = await acmeOrderDAL.transaction(async (tx) => { const account = (await acmeAccountDAL.findByProjectIdAndAccountId(profileId, accountId))!; const createdOrder = await acmeOrderDAL.create( @@ -497,10 +521,10 @@ export const pkiAcmeServiceFactory = ({ const authorizations: TPkiAcmeAuths[] = await Promise.all( payload.identifiers.map(async (identifier) => { if (identifier.type !== AcmeIdentifierType.DNS) { - throw new AcmeUnsupportedIdentifierError({ detail: "Only DNS identifiers are supported" }); + throw new AcmeUnsupportedIdentifierError({ message: "Only DNS identifiers are supported" }); } if (isPrivateIp(identifier.value)) { - throw new AcmeUnsupportedIdentifierError({ detail: "Private IP addresses are not allowed" }); + throw new AcmeUnsupportedIdentifierError({ message: "Private IP addresses are not allowed" }); } const auth = await acmeAuthDAL.create( { @@ -617,11 +641,12 @@ export const pkiAcmeServiceFactory = ({ notAfter: finalizingOrder.notAfter ? new Date(finalizingOrder.notAfter) : undefined, validity: !finalizingOrder.notAfter ? { + // 47 days, the default TTL comes with Let's Encrypt // TODO: read config from the profile to get the expiration time instead - ttl: (24 * 60 * 60 * 1000).toString() + ttl: `${47}d` } : // ttl is not used if notAfter is provided - ({ ttl: "0" } as const), + ({ ttl: "0d" } as const), enrollmentType: EnrollmentType.ACME }); // TODO: associate the certificate with the order @@ -647,9 +672,9 @@ export const pkiAcmeServiceFactory = ({ logger.error(exp, "Failed to sign certificate"); // TODO: audit log the error if (exp instanceof BadRequestError) { - errorToReturn = new AcmeBadCSRError({ detail: `Invalid CSR: ${exp.message}` }); + errorToReturn = new AcmeBadCSRError({ message: `Invalid CSR: ${exp.message}` }); } else { - errorToReturn = new AcmeServerInternalError({ detail: "Failed to sign certificate with internal error" }); + errorToReturn = new AcmeServerInternalError({ message: "Failed to sign certificate with internal error" }); } } return { diff --git a/backend/src/server/plugins/error-handler.ts b/backend/src/server/plugins/error-handler.ts index 4b29f6930..e703df5ef 100644 --- a/backend/src/server/plugins/error-handler.ts +++ b/backend/src/server/plugins/error-handler.ts @@ -252,8 +252,7 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider error: error.name, status: error.status, type: `urn:ietf:params:acme:error:${error.type}`, - detail: error.detail, - message: error.message + detail: error.message // TODO: add subproblems if they exist }); } else { diff --git a/docker-compose.bdd.yml b/docker-compose.bdd.yml new file mode 100644 index 000000000..dfee5f6b2 --- /dev/null +++ b/docker-compose.bdd.yml @@ -0,0 +1,84 @@ +version: "3.9" + +services: + nginx: + container_name: infisical-bdd-nginx + image: nginx + restart: "always" + ports: + - 8080:80 + - 8443:443 + volumes: + - ./nginx/default.dev.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - backend + - frontend + + db: + image: postgres:14-alpine + ports: + - "5432:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + environment: + POSTGRES_PASSWORD: infisical + POSTGRES_USER: infisical + POSTGRES_DB: infisical + + redis: + image: redis + container_name: infisical-bdd-redis + environment: + - ALLOW_EMPTY_PASSWORD=yes + ports: + - 6379:6379 + volumes: + - redis_data:/data + + + backend: + container_name: infisical-bdd-api + build: + context: ./backend + dockerfile: Dockerfile.dev + depends_on: + db: + condition: service_started + redis: + condition: service_started + env_file: + - .env + ports: + - 4000:4000 + - 9464:9464 # for OTEL collection of Prometheus metrics + environment: + - NODE_ENV=development + - DB_CONNECTION_URI=postgres://infisical:infisical@db/infisical?sslmode=disable + - TELEMETRY_ENABLED=false + volumes: + - ./backend/src:/app/src + - softhsm_tokens:/etc/softhsm2/tokens # SoftHSM tokens are stored in a volume to persist across container restarts + extra_hosts: + - "host.docker.internal:host-gateway" + + # TODO: not really needed, but it seems like nginx needs it to be present + frontend: + container_name: infisical-bdd-frontend + restart: unless-stopped + depends_on: + - backend + build: + context: ./frontend + dockerfile: Dockerfile.dev + volumes: + - ./frontend/src:/app/src/ # mounted whole src to avoid missing reload on new files + - ./frontend/public:/app/public + env_file: .env + +volumes: + postgres-data: + driver: local + redis_data: + driver: local + softhsm_tokens: + driver: local \ No newline at end of file