Merge pull request #4829 from Infisical/PKI-33-pki-acme-corner-cases

[PKI-33] Add more automatic tests to cover the PKI ACME security focused corner cases
This commit is contained in:
Fang-Pen Lin
2025-11-12 09:42:08 -08:00
committed by GitHub
20 changed files with 1294 additions and 326 deletions
+101
View File
@@ -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
+1
View File
@@ -71,5 +71,6 @@ frontend-build
cli/infisical-merge cli/infisical-merge
cli/test/infisical-merge cli/test/infisical-merge
/backend/binary /backend/binary
backend/bdd/.bdd-infisical-bootstrap-result.json
/npm/bin /npm/bin
+184 -10
View File
@@ -1,26 +1,200 @@
import json
import os import os
import pathlib
import httpx import httpx
from behave.runner import Context from behave.runner import Context
from dotenv import load_dotenv from dotenv import load_dotenv
from faker import Faker
import logging
load_dotenv() load_dotenv()
logger = logging.getLogger(__name__)
BASE_URL = os.environ.get("INFISICAL_API_URL", "http://localhost:8080") BASE_URL = os.environ.get("INFISICAL_API_URL", "http://localhost:8080")
PROJECT_ID = os.environ.get("PROJECT_ID") PROJECT_ID = os.environ.get("PROJECT_ID")
CERT_CA_ID = os.environ.get("CERT_CA_ID") CERT_CA_ID = os.environ.get("CERT_CA_ID")
CERT_TEMPLATE_ID = os.environ.get("CERT_TEMPLATE_ID") CERT_TEMPLATE_ID = os.environ.get("CERT_TEMPLATE_ID")
AUTH_TOKEN = os.environ.get("INFISICAL_TOKEN") 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): def before_all(context: Context):
context.vars = { if BOOTSTRAP_INFISICAL:
"BASE_URL": BASE_URL, details = bootstrap_infisical(context)
"PROJECT_ID": PROJECT_ID, context.vars = {
"CERT_CA_ID": CERT_CA_ID, "BASE_URL": BASE_URL,
"CERT_TEMPLATE_ID": CERT_TEMPLATE_ID, "PROJECT_ID": details["project"]["id"],
"AUTH_TOKEN": AUTH_TOKEN, "CERT_CA_ID": details["ca"]["id"],
} "CERT_TEMPLATE_ID": details["cert_template"]["id"],
context.http_client = httpx.Client( "AUTH_TOKEN": details["auth_token"],
base_url=BASE_URL, # headers={"Authorization": f"Bearer {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)
@@ -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>[^/]+)$") | .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 <src_var> with jq "<jq>" as <dest_var>
When I send a raw ACME request to "<url>"
"""
{
"protected": {
"alg": "RS256",
"nonce": "{nonce}",
"url": "<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 "<url>"
"""
{
"protected": {
"alg": "RS256",
"nonce": "{nonce}",
"url": "<url>",
"kid": "{acme_account1.uri}"
},
"raw_payload": "<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>[^/]+)$") | .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 <src_var> with jq "<jq>" as <dest_var>
When I send a raw ACME request to "<url>"
"""
{
"protected": {
"alg": "RS256",
"nonce": "{nonce}",
"url": "<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 <src_var> with jq "<jq>" as <dest_var>
When I send a raw ACME request to "<url>"
"""
{
"protected": {
"alg": "RS256",
"nonce": "{nonce}",
"url": "<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>[^/]+)$") | .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 <src_var> with jq "<jq>" as <dest_var>
When I send a raw ACME request to "<url>"
"""
{
"protected": {
"alg": "RS256",
"nonce": "{nonce}",
"url": "<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 <src_var> with jq "<jq>" as <dest_var>
When I send a raw ACME request to "<url>"
"""
{
"protected": {
"alg": "RS256",
"nonce": "{nonce}",
"url": "<url>",
"kid": "{acme_account1.uri}"
},
"raw_payload": "<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>[^/]+)$") | .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 <src_var> with jq "<jq>" as <dest_var>
When I send a raw ACME request to "<actual_url>"
"""
{
"protected": {
"alg": "RS256",
"nonce": "{nonce}",
"url": "<bad_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 "<error_detail>"
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 |
+49 -1
View File
@@ -2,5 +2,53 @@ Feature: Account
Scenario: Create a new account Scenario: Create a new account
Given I have an ACME cert profile as "acme_profile" 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 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 "<eab_kid>" with secret "<eab_secret>" as acme_account
And the value error with jq ".type" should be equal to "<error_type>"
And the value error with jq ".detail" should be equal to "<error_msg>"
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 "<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 |
+8 -9
View File
@@ -2,8 +2,7 @@ Feature: Authorization
Scenario: Get authorization Scenario: Get authorization
Given I have an ACME cert profile as "acme_profile" 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"
# # TODO: make it I have an account already instead?
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 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 When I create certificate signing request as csr
Then I add names to certificate signing request 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 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 And 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 And 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/(.+) And 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" And 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 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"] And 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 ".identifier" should be equal to json
""" """
{ {
"type": "dns", "type": "dns",
@@ -2,8 +2,8 @@ Feature: ACME Cert Profile
Scenario: Create a cert profile Scenario: Create a cert profile
Given I make a random slug as profile_slug Given I make a random slug as profile_slug
Given I use AUTH_TOKEN for authentication And I use AUTH_TOKEN for authentication
When I send a POST request to "/api/v1/pki/certificate-profiles" with JSON payload When I send a "POST" request to "/api/v1/pki/certificate-profiles" with JSON payload
""" """
{ {
"projectId": "{PROJECT_ID}", "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.status_code should be equal to 200
Then the value response with jq ".certificateProfile.id" should be present And the value response with jq ".certificateProfile.id" should be present
Then the value response with jq ".certificateProfile.slug" should be equal to "{profile_slug}" And 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}" And 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}" And 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.enrollmentType" should be equal to "acme"
Scenario: Reveal EAB secret Scenario: Reveal EAB secret
Given I make a random slug as profile_slug Given I make a random slug as profile_slug
Given I use AUTH_TOKEN for authentication And I use AUTH_TOKEN for authentication
When I send a POST request to "/api/v1/pki/certificate-profiles" with JSON payload When I send a "POST" request to "/api/v1/pki/certificate-profiles" with JSON payload
""" """
{ {
"projectId": "{PROJECT_ID}", "projectId": "{PROJECT_ID}",
@@ -39,11 +39,11 @@ Feature: ACME Cert Profile
""" """
Then the value response.status_code should be equal to 200 Then the value response.status_code should be equal to 200
And I memorize response with jq ".certificateProfile.id" as profile_id 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.status_code should be equal to 200
Then the value response with jq ".eabKid" should be equal to "{profile_id}" And 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 ".eabSecret" should be present
And I memorize response with jq ".eabKid" as eab_kid And I memorize response with jq ".eabKid" as eab_kid
And I memorize response with jq ".eabSecret" as eab_secret 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 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
@@ -2,8 +2,7 @@ Feature: Challenge
Scenario: Validate challenge Scenario: Validate challenge
Given I have an ACME cert profile as "acme_profile" 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"
# # TODO: make it I have an account already instead?
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 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 When I create certificate signing request as csr
Then I add names to certificate signing request csr Then I add names to certificate signing request csr
@@ -12,12 +11,11 @@ Feature: Challenge
"COMMON_NAME": "localhost" "COMMON_NAME": "localhost"
} }
""" """
Then I create a RSA private key pair as cert_key And 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 And 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 And 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 And 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 And I serve challenge response for challenge at localhost
Then I tell ACME server that challenge is ready to be verified And I tell ACME server that challenge is ready to be verified
Then I poll and finalize the ACME order order as finalized_order And 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" And the value finalized_order.body with jq ".status" should be equal to "valid"
# TODO: check the fullchain pem content of the order
@@ -2,13 +2,13 @@ Feature: Directory
Scenario: Get the directory of ACME service urls Scenario: Get the directory of ACME service urls
Given I have an ACME cert profile as "acme_profile" 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 status code should be "200"
Then the response body should match JSON value And the response body should match JSON value
""" """
{ {
"newNonce": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-nonce", "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", "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" "newOrder": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order"
} }
""" """
+102 -2
View File
@@ -2,6 +2,106 @@ Feature: Nonce
Scenario: Generate a new nonce Scenario: Generate a new nonce
Given I have an ACME cert profile as "acme_profile" 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 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>[^/]+)$") | .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 <src_var> with jq "<jq>" as <dest_var>
When I send a raw ACME request to "<url>"
"""
{
"protected": {
"alg": "RS256",
"nonce": "oFvnlFP1wIhRlYS2jTaXbA",
"url": "<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>[^/]+)$") | .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 <src_var> with jq "<jq>" as <dest_var>
When I send a raw ACME request to "<url>"
"""
{
"protected": {
"alg": "RS256",
"nonce": "{nonce_value}",
"url": "<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} |
+93 -25
View File
@@ -2,8 +2,7 @@ Feature: Order
Scenario: Create a new order Scenario: Create a new order
Given I have an ACME cert profile as "acme_profile" 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"
# # TODO: make it I have an account already instead?
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 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 When I create certificate signing request as csr
Then I add names to certificate signing request 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 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 And 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 And 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/(.+) And 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" And 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"}] And 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 And 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 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 Scenario: Create a new order with SANs
Given I have an ACME cert profile as "acme_profile" 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"
# # TODO: make it I have an account already instead?
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 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 When I create certificate signing request as csr
Then I add names to certificate signing request csr Then I add names to certificate signing request csr
@@ -33,17 +31,17 @@ Feature: Order
"COMMON_NAME": "localhost" "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", "example.com",
"infisical.com" "infisical.com"
] ]
""" """
Then I create a RSA private key pair as cert_key And 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 And 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 And 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 the value order.body with jq ".identifiers | sort_by(.value)" should be equal to json
""" """
[ [
{"type": "dns", "value": "example.com"}, {"type": "dns", "value": "example.com"},
@@ -54,8 +52,7 @@ Feature: Order
Scenario: Fetch an order Scenario: Fetch an order
Given I have an ACME cert profile as "acme_profile" 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"
# # TODO: make it I have an account already instead?
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 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 When I create certificate signing request as csr
Then I add names to certificate signing request 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 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 And 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 And 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 And 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" And 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"}] And 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 And 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 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": "<identifier_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": "<identifier_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 |
+186 -27
View File
@@ -1,9 +1,10 @@
import json import json
import logging import logging
import os
import re import re
import threading import threading
import urllib.parse
import acme.client
import httpx import httpx
import jq import jq
import requests import requests
@@ -12,12 +13,14 @@ from faker import Faker
from acme import client from acme import client
from acme import messages from acme import messages
from acme import standalone from acme import standalone
from acme.jws import Signature
from behave.runner import Context from behave.runner import Context
from behave import given from behave import given
from behave import when from behave import when
from behave import then from behave import then
from josepy.jwk import JWKRSA from josepy.jwk import JWKRSA
from josepy import JSONObjectWithFields from josepy import JSONObjectWithFields
from josepy import json_util
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography import x509 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}"') @given('I have an ACME cert profile as "{profile_var}"')
def step_impl(context: Context, profile_var: str): def step_impl(context: Context, profile_var: str):
# TODO: Fixed value for now, just to make test much easier, profile_id = context.vars.get("PROFILE_ID")
# we should call infisical API to create such profile instead secret = context.vars.get("EAB_SECRET")
# in the future if profile_id is not None and secret is not None:
profile_id = os.getenv("PROFILE_ID") kid = profile_id
kid = profile_id else:
secret = os.getenv("EAB_SECRET") 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( context.vars[profile_var] = AcmeProfile(
profile_id, profile_id,
eab_kid=kid, eab_kid=kid,
@@ -152,7 +182,7 @@ def step_impl(context: Context, token_var: str):
context.auth_token = eval_var(context, token_var) 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): def step_impl(context: Context, method: str, url: str):
logger.debug("Sending %s request to %s", method, url) logger.debug("Sending %s request to %s", method, url)
response = context.http_client.request( response = context.http_client.request(
@@ -166,7 +196,7 @@ def step_impl(context: Context, method: str, url: str):
pass 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): def step_impl(context: Context, method: str, url: str):
json_payload = json.loads(context.text) json_payload = json.loads(context.text)
json_payload = replace_vars(json_payload, context.vars) 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()) logger.debug("Response JSON payload: %r", response.json())
@when("I have an ACME client connecting to {url}") def create_acme_client(context: Context, url: str, acc_jwk: JWKRSA | None = None):
def step_impl(context: Context, url: str): if acc_jwk is None:
private_key = rsa.generate_private_key( private_key = rsa.generate_private_key(
public_exponent=ACC_KEY_PUBLIC_EXPONENT, key_size=ACC_KEY_BITS public_exponent=ACC_KEY_PUBLIC_EXPONENT, key_size=ACC_KEY_BITS
) )
pem_bytes = private_key.private_bytes( pem_bytes = private_key.private_bytes(
encoding=serialization.Encoding.PEM, encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8, format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(), encryption_algorithm=serialization.NoEncryption(),
) )
acc_jwk = JWKRSA.load(pem_bytes) acc_jwk = JWKRSA.load(pem_bytes)
net = client.ClientNetwork(acc_jwk) net = client.ClientNetwork(acc_jwk)
directory_url = url.format(**context.vars) directory_url = url.format(**context.vars)
directory = client.ClientV2.get_directory(directory_url, net) directory = client.ClientV2.get_directory(directory_url, net)
context.acme_client = client.ClientV2(directory, net=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}"') @then('the response status code should be "{expected_status_code:d}"')
def step_impl(context: Context, expected_status_code: int): def step_impl(context: Context, expected_status_code: int):
assert context.vars["response"].status_code == expected_status_code, ( assert context.vars["response"].status_code == expected_status_code, (
@@ -228,24 +269,122 @@ def step_impl(context: Context):
assert payload == replaced, f"{payload} != {replaced}" assert payload == replaced, f"{payload} != {replaced}"
@then( @when('I use a different new-account URL "{url}" for EAB signature')
'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, url: str):
) context.alt_eab_url = replace_vars(url, context.vars)
def step_impl(context: Context, email: str, kid: str, secret: str, account_var: str):
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 acme_client = context.acme_client
account_public_key = acme_client.net.key.public_key() 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( eab = messages.ExternalAccountBinding.from_data(
account_public_key=account_public_key, account_public_key=account_public_key,
kid=replace_vars(kid, context.vars), kid=replace_vars(kid, context.vars),
hmac_key=replace_vars(secret, context.vars), hmac_key=replace_vars(secret, context.vars),
directory=acme_client.directory, directory=eab_directory,
hmac_alg="HS256", hmac_alg="HS256",
) )
registration = messages.NewRegistration.from_data( registration = messages.NewRegistration.from_data(
email=email, email=email,
external_account_binding=eab, 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( @then(
@@ -384,10 +523,17 @@ def step_impl(context: Context, var_path: str):
@then("the value {var_path} should be equal to {expected}") @then("the value {var_path} should be equal to {expected}")
def step_impl(context: Context, var_path: str, expected: str): def step_impl(context: Context, var_path: str, expected: str):
value = eval_var(context, var_path) 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}" 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}') @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): def step_impl(context: Context, var_path: str, jq_query, var_name: str):
_, value = apply_value_with_jq( _, 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 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}") @then("I memorize {var_path} as {var_name}")
def step_impl(context: Context, var_path: str, var_name: str): def step_impl(context: Context, var_path: str, var_name: str):
value = eval_var(context, var_path) value = eval_var(context, var_path)
+1
View File
@@ -44,6 +44,7 @@
"test:e2e": "vitest run -c vitest.e2e.config.mts --bail=1", "test:e2e": "vitest run -c vitest.e2e.config.mts --bail=1",
"test:e2e-watch": "vitest -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: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:component": "tsx ./scripts/create-backend-file.ts",
"generate:schema": "tsx ./scripts/generate-schema-types.ts && eslint --fix --ext ts ./src/db/schemas", "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", "auditlog-migration:latest": "node ./dist/db/rename-migrations-to-mjs.mjs && knex --knexfile ./dist/db/auditlog-knexfile.mjs --client pg migrate:latest",
+3 -3
View File
@@ -261,7 +261,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => {
req req
}); });
if (payload !== "") { if (payload !== "") {
throw new AcmeMalformedError({ detail: "Payload should be empty" }); throw new AcmeMalformedError({ message: "Payload should be empty" });
} }
return sendAcmeResponse( return sendAcmeResponse(
res, res,
@@ -373,7 +373,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => {
req req
}); });
if (payload !== "") { if (payload !== "") {
throw new AcmeMalformedError({ detail: "Payload should be empty" }); throw new AcmeMalformedError({ message: "Payload should be empty" });
} }
res.type("application/pem-certificate-chain"); res.type("application/pem-certificate-chain");
return sendAcmeResponse( return sendAcmeResponse(
@@ -407,7 +407,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => {
handler: async (req, res) => { handler: async (req, res) => {
const { profileId, accountId, payload } = await validateExistingAccount({ req }); const { profileId, accountId, payload } = await validateExistingAccount({ req });
if (payload !== "") { if (payload !== "") {
throw new AcmeMalformedError({ detail: "Payload should be empty" }); throw new AcmeMalformedError({ message: "Payload should be empty" });
} }
return sendAcmeResponse( return sendAcmeResponse(
res, res,
@@ -35,7 +35,7 @@ export enum AcmeErrorType {
export interface IAcmeError { export interface IAcmeError {
type: AcmeErrorType; type: AcmeErrorType;
detail: string; message: string;
status: number; status: number;
subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>; 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 { export class AcmeError extends Error implements IAcmeError {
type: AcmeErrorType; type: AcmeErrorType;
detail: string; message: string;
status: number; status: number;
@@ -53,22 +53,20 @@ export class AcmeError extends Error implements IAcmeError {
constructor({ constructor({
type, type,
detail, message,
status, status,
subproblems, subproblems,
error, error
message
}: { }: {
type: AcmeErrorType; type: AcmeErrorType;
detail: string; message: string;
status: number; status: number;
subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>; subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>;
error?: unknown; error?: unknown;
message?: string;
}) { }) {
super(message || detail); super(message);
this.type = type; this.type = type;
this.detail = detail; this.message = message;
this.status = status; this.status = status;
this.subproblems = subproblems; this.subproblems = subproblems;
this.error = error; this.error = error;
@@ -78,7 +76,7 @@ export class AcmeError extends Error implements IAcmeError {
toAcmeResponse(): IAcmeError { toAcmeResponse(): IAcmeError {
return { return {
type: this.type, type: this.type,
detail: this.detail, message: this.message,
status: this.status, status: this.status,
subproblems: this.subproblems subproblems: this.subproblems
}; };
@@ -90,20 +88,17 @@ export class AcmeError extends Error implements IAcmeError {
*/ */
export class AcmeMalformedError extends AcmeError { export class AcmeMalformedError extends AcmeError {
constructor({ constructor({
detail = "The request message was malformed", message = "The request message was malformed",
error, error
message
}: { }: {
detail?: string;
error?: unknown;
message?: string; message?: string;
error?: unknown;
} = {}) { } = {}) {
super({ super({
type: AcmeErrorType.Malformed, type: AcmeErrorType.Malformed,
detail, message,
status: 400, status: 400,
error, error
message
}); });
this.name = "AcmeMalformedError"; this.name = "AcmeMalformedError";
} }
@@ -114,20 +109,17 @@ export class AcmeMalformedError extends AcmeError {
*/ */
export class AcmeUnauthorizedError extends AcmeError { export class AcmeUnauthorizedError extends AcmeError {
constructor({ constructor({
detail = "The client lacks sufficient authorization", message = "The client lacks sufficient authorization",
error, error
message
}: { }: {
detail?: string;
error?: unknown;
message?: string; message?: string;
error?: unknown;
} = {}) { } = {}) {
super({ super({
type: AcmeErrorType.Unauthorized, type: AcmeErrorType.Unauthorized,
detail, message,
status: 403, status: 403,
error, error
message
}); });
this.name = "AcmeUnauthorizedError"; this.name = "AcmeUnauthorizedError";
} }
@@ -139,20 +131,17 @@ export class AcmeUnauthorizedError extends AcmeError {
*/ */
export class AcmeAccountDoesNotExistError extends AcmeError { export class AcmeAccountDoesNotExistError extends AcmeError {
constructor({ constructor({
detail = "The request specified an account that does not exist", message = "The request specified an account that does not exist",
error, error
message
}: { }: {
detail?: string;
error?: unknown;
message?: string; message?: string;
error?: unknown;
} = {}) { } = {}) {
super({ super({
type: AcmeErrorType.AccountDoesNotExist, type: AcmeErrorType.AccountDoesNotExist,
detail, message,
status: 400, status: 404,
error, error
message
}); });
this.name = "AcmeAccountDoesNotExistError"; this.name = "AcmeAccountDoesNotExistError";
} }
@@ -163,20 +152,17 @@ export class AcmeAccountDoesNotExistError extends AcmeError {
*/ */
export class AcmeBadNonceError extends AcmeError { export class AcmeBadNonceError extends AcmeError {
constructor({ constructor({
detail = "The client sent an unacceptable anti-replay nonce", message = "The client sent an unacceptable anti-replay nonce",
error, error
message
}: { }: {
detail?: string;
error?: unknown;
message?: string; message?: string;
error?: unknown;
} = {}) { } = {}) {
super({ super({
type: AcmeErrorType.BadNonce, type: AcmeErrorType.BadNonce,
detail, message,
status: 400, status: 400,
error, error
message
}); });
this.name = "AcmeBadNonceError"; this.name = "AcmeBadNonceError";
} }
@@ -187,20 +173,17 @@ export class AcmeBadNonceError extends AcmeError {
*/ */
export class AcmeBadSignatureAlgorithmError extends AcmeError { export class AcmeBadSignatureAlgorithmError extends AcmeError {
constructor({ constructor({
detail = "The signature algorithm is invalid", message = "The signature algorithm is invalid",
error, error
message
}: { }: {
detail?: string;
error?: unknown;
message?: string; message?: string;
error?: unknown;
} = {}) { } = {}) {
super({ super({
type: AcmeErrorType.BadSignatureAlgorithm, type: AcmeErrorType.BadSignatureAlgorithm,
detail, message,
status: 401, status: 401,
error, error
message
}); });
this.name = "AcmeBadSignatureAlgorithmError"; this.name = "AcmeBadSignatureAlgorithmError";
} }
@@ -211,20 +194,17 @@ export class AcmeBadSignatureAlgorithmError extends AcmeError {
*/ */
export class AcmeBadPublicKeyError extends AcmeError { export class AcmeBadPublicKeyError extends AcmeError {
constructor({ constructor({
detail = "The public key is not acceptable", message = "The public key is not acceptable",
error, error
message
}: { }: {
detail?: string;
error?: unknown;
message?: string; message?: string;
error?: unknown;
} = {}) { } = {}) {
super({ super({
type: AcmeErrorType.BadPublicKey, type: AcmeErrorType.BadPublicKey,
detail, message,
status: 400, status: 400,
error, error
message
}); });
this.name = "AcmeBadPublicKeyError"; this.name = "AcmeBadPublicKeyError";
} }
@@ -235,20 +215,17 @@ export class AcmeBadPublicKeyError extends AcmeError {
*/ */
export class AcmeBadCsrError extends AcmeError { export class AcmeBadCsrError extends AcmeError {
constructor({ constructor({
detail = "The CSR is unacceptable", message = "The CSR is unacceptable",
error, error
message
}: { }: {
detail?: string;
error?: unknown;
message?: string; message?: string;
error?: unknown;
} = {}) { } = {}) {
super({ super({
type: AcmeErrorType.BadCsr, type: AcmeErrorType.BadCsr,
detail, message,
status: 400, status: 400,
error, error
message
}); });
this.name = "AcmeBadCsrError"; this.name = "AcmeBadCsrError";
} }
@@ -260,20 +237,17 @@ export class AcmeBadCsrError extends AcmeError {
*/ */
export class AcmeBadRevocationReasonError extends AcmeError { export class AcmeBadRevocationReasonError extends AcmeError {
constructor({ constructor({
detail = "The revocation reason provided is not allowed", message = "The revocation reason provided is not allowed",
error, error
message
}: { }: {
detail?: string;
error?: unknown;
message?: string; message?: string;
error?: unknown;
} = {}) { } = {}) {
super({ super({
type: AcmeErrorType.BadRevocationReason, type: AcmeErrorType.BadRevocationReason,
detail, message,
status: 400, status: 400,
error, error
message
}); });
this.name = "AcmeBadRevocationReasonError"; this.name = "AcmeBadRevocationReasonError";
} }
@@ -284,20 +258,17 @@ export class AcmeBadRevocationReasonError extends AcmeError {
*/ */
export class AcmeRateLimitedError extends AcmeError { export class AcmeRateLimitedError extends AcmeError {
constructor({ constructor({
detail = "The client has exceeded a rate limit", message = "The client has exceeded a rate limit",
error, error
message
}: { }: {
detail?: string;
error?: unknown;
message?: string; message?: string;
error?: unknown;
} = {}) { } = {}) {
super({ super({
type: AcmeErrorType.RateLimited, type: AcmeErrorType.RateLimited,
detail, message,
status: 429, status: 429,
error, error
message
}); });
this.name = "AcmeRateLimitedError"; this.name = "AcmeRateLimitedError";
} }
@@ -309,23 +280,20 @@ export class AcmeRateLimitedError extends AcmeError {
*/ */
export class AcmeRejectedIdentifierError extends AcmeError { export class AcmeRejectedIdentifierError extends AcmeError {
constructor({ constructor({
detail = "The server will not issue certificates for the identifier", message = "The server will not issue certificates for the identifier",
subproblems, subproblems,
error, error
message
}: { }: {
detail?: string; message?: string;
subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>; subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>;
error?: unknown; error?: unknown;
message?: string;
} = {}) { } = {}) {
super({ super({
type: AcmeErrorType.RejectedIdentifier, type: AcmeErrorType.RejectedIdentifier,
detail, message,
status: 400, status: 400,
subproblems, subproblems,
error, error
message
}); });
this.name = "AcmeRejectedIdentifierError"; this.name = "AcmeRejectedIdentifierError";
} }
@@ -336,20 +304,17 @@ export class AcmeRejectedIdentifierError extends AcmeError {
*/ */
export class AcmeServerInternalError extends AcmeError { export class AcmeServerInternalError extends AcmeError {
constructor({ constructor({
detail = "An internal error occurred", message = "An internal error occurred",
error, error
message
}: { }: {
detail?: string;
error?: unknown;
message?: string; message?: string;
error?: unknown;
} = {}) { } = {}) {
super({ super({
type: AcmeErrorType.ServerInternal, type: AcmeErrorType.ServerInternal,
detail, message,
status: 500, status: 500,
error, error
message
}); });
this.name = "AcmeServerInternalError"; this.name = "AcmeServerInternalError";
} }
@@ -360,20 +325,17 @@ export class AcmeServerInternalError extends AcmeError {
*/ */
export class AcmeUnsupportedContactError extends AcmeError { export class AcmeUnsupportedContactError extends AcmeError {
constructor({ constructor({
detail = "A contact URL is of an unsupported type", message = "A contact URL is of an unsupported type",
error, error
message
}: { }: {
detail?: string;
error?: unknown;
message?: string; message?: string;
error?: unknown;
} = {}) { } = {}) {
super({ super({
type: AcmeErrorType.UnsupportedContact, type: AcmeErrorType.UnsupportedContact,
detail, message,
status: 400, status: 400,
error, error
message
}); });
this.name = "AcmeUnsupportedContactError"; this.name = "AcmeUnsupportedContactError";
} }
@@ -385,20 +347,17 @@ export class AcmeUnsupportedContactError extends AcmeError {
*/ */
export class AcmeUnsupportedIdentifierError extends AcmeError { export class AcmeUnsupportedIdentifierError extends AcmeError {
constructor({ constructor({
detail = "An identifier is of an unsupported type", message = "An identifier is of an unsupported type",
error, error
message
}: { }: {
detail?: string;
error?: unknown;
message?: string; message?: string;
error?: unknown;
} = {}) { } = {}) {
super({ super({
type: AcmeErrorType.UnsupportedIdentifier, type: AcmeErrorType.UnsupportedIdentifier,
detail, message,
status: 400, status: 400,
error, error
message
}); });
this.name = "AcmeUnsupportedIdentifierError"; this.name = "AcmeUnsupportedIdentifierError";
} }
@@ -412,22 +371,19 @@ export class AcmeUserActionRequiredError extends AcmeError {
instance?: string; instance?: string;
constructor({ constructor({
detail = "Visit the instance URL and take actions specified there", message = "Visit the instance URL and take actions specified there",
instance, instance,
error, error
message
}: { }: {
detail?: string; message?: string;
instance?: string; instance?: string;
error?: unknown; error?: unknown;
message?: string;
} = {}) { } = {}) {
super({ super({
type: AcmeErrorType.UserActionRequired, type: AcmeErrorType.UserActionRequired,
detail, message,
status: 403, status: 403,
error, error
message
}); });
this.instance = instance; this.instance = instance;
this.name = "AcmeUserActionRequiredError"; this.name = "AcmeUserActionRequiredError";
@@ -446,20 +402,17 @@ export class AcmeUserActionRequiredError extends AcmeError {
*/ */
export class AcmeIncorrectResponseError extends AcmeError { export class AcmeIncorrectResponseError extends AcmeError {
constructor({ constructor({
detail = "The response is incorrect", message = "The response is incorrect",
error, error
message
}: { }: {
detail?: string;
error?: unknown;
message?: string; message?: string;
error?: unknown;
} = {}) { } = {}) {
super({ super({
type: AcmeErrorType.IncorrectResponse, type: AcmeErrorType.IncorrectResponse,
detail, message,
status: 400, status: 400,
error, error
message
}); });
this.name = "AcmeIncorrectResponseError"; this.name = "AcmeIncorrectResponseError";
} }
@@ -470,20 +423,17 @@ export class AcmeIncorrectResponseError extends AcmeError {
*/ */
export class AcmeConnectionError extends AcmeError { export class AcmeConnectionError extends AcmeError {
constructor({ constructor({
detail = "A connection error occurred", message = "A connection error occurred",
error, error
message
}: { }: {
detail?: string;
error?: unknown;
message?: string; message?: string;
error?: unknown;
} = {}) { } = {}) {
super({ super({
type: AcmeErrorType.Connection, type: AcmeErrorType.Connection,
detail, message,
status: 400, status: 400,
error, error
message
}); });
this.name = "AcmeConnectionError"; this.name = "AcmeConnectionError";
} }
@@ -491,20 +441,17 @@ export class AcmeConnectionError extends AcmeError {
export class AcmeDnsFailureError extends AcmeError { export class AcmeDnsFailureError extends AcmeError {
constructor({ constructor({
detail = "Hostname could not be resolved (DNS failure)", message = "Hostname could not be resolved (DNS failure)",
error, error
message
}: { }: {
detail?: string;
error?: unknown;
message?: string; message?: string;
error?: unknown;
} = {}) { } = {}) {
super({ super({
type: AcmeErrorType.DNS, type: AcmeErrorType.DNS,
detail, message,
status: 400, status: 400,
error, error
message
}); });
this.name = "AcmeDnsFailureError"; this.name = "AcmeDnsFailureError";
} }
@@ -512,20 +459,17 @@ export class AcmeDnsFailureError extends AcmeError {
export class AcmeOrderNotReadyError extends AcmeError { export class AcmeOrderNotReadyError extends AcmeError {
constructor({ constructor({
detail = "The order is not ready", message = "The order is not ready",
error, error
message
}: { }: {
detail?: string;
error?: unknown;
message?: string; message?: string;
error?: unknown;
} = {}) { } = {}) {
super({ super({
type: AcmeErrorType.OrderNotReady, type: AcmeErrorType.OrderNotReady,
detail, message,
status: 403, status: 403,
error, error
message
}); });
this.name = "AcmeOrderNotReadyError"; this.name = "AcmeOrderNotReadyError";
} }
@@ -533,20 +477,17 @@ export class AcmeOrderNotReadyError extends AcmeError {
export class AcmeBadCSRError extends AcmeError { export class AcmeBadCSRError extends AcmeError {
constructor({ constructor({
detail = "The CSR is unacceptable", message = "The CSR is unacceptable",
error, error
message
}: { }: {
detail?: string;
error?: unknown;
message?: string; message?: string;
error?: unknown;
} = {}) { } = {}) {
super({ super({
type: AcmeErrorType.BadCsr, type: AcmeErrorType.BadCsr,
detail, message,
status: 400, status: 400,
error, error
message
}); });
this.name = "AcmeBadCSRError"; this.name = "AcmeBadCSRError";
} }
@@ -554,20 +495,17 @@ export class AcmeBadCSRError extends AcmeError {
export class AcmeExternalAccountRequiredError extends AcmeError { export class AcmeExternalAccountRequiredError extends AcmeError {
constructor({ constructor({
detail = "External account binding is required", message = "External account binding is required",
error, error
message
}: { }: {
detail?: string;
error?: unknown;
message?: string; message?: string;
error?: unknown;
} = {}) { } = {}) {
super({ super({
type: AcmeErrorType.ExternalAccountRequired, type: AcmeErrorType.ExternalAccountRequired,
detail, message,
status: 400, status: 400,
error, error
message
}); });
this.name = "AcmeExternalAccountRequiredError"; this.name = "AcmeExternalAccountRequiredError";
} }
@@ -1,8 +1,7 @@
import { z } from "zod";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import RE2 from "re2";
import { AcmeMalformedError } from "./pki-acme-errors"; import { z } from "zod";
import { AcmeAccountDoesNotExistError } from "./pki-acme-errors";
export const buildUrl = (profileId: string, path: string): string => { export const buildUrl = (profileId: string, path: string): string => {
const appCfg = getConfig(); const appCfg = getConfig();
@@ -13,7 +12,14 @@ export const buildUrl = (profileId: string, path: string): string => {
export const extractAccountIdFromKid = (kid: string, profileId: string): string => { export const extractAccountIdFromKid = (kid: string, profileId: string): string => {
const kidPrefix = buildUrl(profileId, "/accounts/"); const kidPrefix = buildUrl(profileId, "/accounts/");
if (!kid.startsWith(kidPrefix)) { 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)); 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));
};
@@ -1,4 +1,3 @@
import RE2 from "re2";
import { z } from "zod"; import { z } from "zod";
export enum AcmeIdentifierType { export enum AcmeIdentifierType {
@@ -88,13 +87,8 @@ export const CreateAcmeAccountResponseSchema = z.object({
export const CreateAcmeOrderBodySchema = z.object({ export const CreateAcmeOrderBodySchema = z.object({
identifiers: z.array( identifiers: z.array(
z.object({ z.object({
type: z.enum(Object.values(AcmeIdentifierType) as [string, ...string[]]), type: z.string(),
value: z.string().refine((val) => { value: z.string()
// 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")
}) })
), ),
notBefore: z.string().optional(), notBefore: z.string().optional(),
@@ -13,21 +13,22 @@ import { TPkiAcmeAccounts } from "@app/db/schemas/pki-acme-accounts";
import { TPkiAcmeAuths } from "@app/db/schemas/pki-acme-auths"; import { TPkiAcmeAuths } from "@app/db/schemas/pki-acme-auths";
import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore";
import { crypto } from "@app/lib/crypto/cryptography"; 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 { isPrivateIp } from "@app/lib/ip/ipRange";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { ActorType } from "@app/services/auth/auth-type"; 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 { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal";
import { import {
EnrollmentType, EnrollmentType,
TCertificateProfileWithConfigs TCertificateProfileWithConfigs
} from "@app/services/certificate-profile/certificate-profile-types"; } from "@app/services/certificate-profile/certificate-profile-types";
import { TCertificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service"; 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 { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal";
import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns";
import { getConfig } from "@app/lib/config/env";
import { TPkiAcmeAccountDALFactory } from "./pki-acme-account-dal"; import { TPkiAcmeAccountDALFactory } from "./pki-acme-account-dal";
import { TPkiAcmeAuthDALFactory } from "./pki-acme-auth-dal"; import { TPkiAcmeAuthDALFactory } from "./pki-acme-auth-dal";
import { TPkiAcmeChallengeDALFactory } from "./pki-acme-challenge-dal"; import { TPkiAcmeChallengeDALFactory } from "./pki-acme-challenge-dal";
@@ -41,10 +42,9 @@ import {
AcmeMalformedError, AcmeMalformedError,
AcmeOrderNotReadyError, AcmeOrderNotReadyError,
AcmeServerInternalError, AcmeServerInternalError,
AcmeUnauthorizedError,
AcmeUnsupportedIdentifierError AcmeUnsupportedIdentifierError
} from "./pki-acme-errors"; } 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 { TPkiAcmeOrderAuthDALFactory } from "./pki-acme-order-auth-dal";
import { TPkiAcmeOrderDALFactory } from "./pki-acme-order-dal"; import { TPkiAcmeOrderDALFactory } from "./pki-acme-order-dal";
import { import {
@@ -148,7 +148,7 @@ export const pkiAcmeServiceFactory = ({
try { try {
result = await flattenedVerify(rawJwsPayload, async (protectedHeader: JWSHeaderParameters | undefined) => { result = await flattenedVerify(rawJwsPayload, async (protectedHeader: JWSHeaderParameters | undefined) => {
if (protectedHeader === 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 jwk = await getJWK(protectedHeader);
const key = await importJWK(jwk, protectedHeader.alg); const key = await importJWK(jwk, protectedHeader.alg);
@@ -159,28 +159,35 @@ export const pkiAcmeServiceFactory = ({
throw error; throw error;
} }
if (error instanceof ZodError) { 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) { 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"); 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; const { protectedHeader: rawProtectedHeader, payload: rawPayload } = result;
try { try {
const protectedHeader = ProtectedHeaderSchema.parse(rawProtectedHeader); 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 // Validate the URL
if (new URL(protectedHeader.url).href !== url.href) { if (parsedUrl.href !== url.href) {
throw new AcmeUnauthorizedError({ detail: "URL mismatch in the protected header" }); throw new AcmeMalformedError({ message: "URL mismatch in the protected header" });
} }
// Consume the nonce // Consume the nonce
if (!protectedHeader.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)); const deleted = await keyStore.deleteItem(KeyStorePrefixes.PkiAcmeNonce(protectedHeader.nonce));
if (deleted !== 1) { if (deleted !== 1) {
throw new AcmeBadNonceError({ detail: "Invalid nonce" }); throw new AcmeBadNonceError({ message: "Invalid nonce" });
} }
// Parse the payload // Parse the payload
@@ -196,10 +203,10 @@ export const pkiAcmeServiceFactory = ({
throw error; throw error;
} }
if (error instanceof ZodError) { 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"); 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, rawJwsPayload,
getJWK: async (protectedHeader) => { getJWK: async (protectedHeader) => {
if (!protectedHeader.jwk) { 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; return protectedHeader.jwk as unknown as JsonWebKey;
}, },
@@ -246,18 +253,18 @@ export const pkiAcmeServiceFactory = ({
rawJwsPayload, rawJwsPayload,
getJWK: async (protectedHeader) => { getJWK: async (protectedHeader) => {
if (!protectedHeader.kid) { 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); const accountId = extractAccountIdFromKid(protectedHeader.kid, profileId);
if (expectedAccountId && accountId !== expectedAccountId) { 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); const account = await acmeAccountDAL.findByProjectIdAndAccountId(profile.id, accountId);
if (!account) { if (!account) {
throw new AcmeAccountDoesNotExistError({ message: "ACME account not found" }); throw new AcmeAccountDoesNotExistError({ message: "ACME account not found" });
} }
if (account.alg !== protectedHeader.alg) { 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; return account.publicKey as JsonWebKey;
}, },
@@ -344,7 +351,7 @@ export const pkiAcmeServiceFactory = ({
}): Promise<TAcmeResponse<TCreateAcmeAccountResponse>> => { }): Promise<TAcmeResponse<TCreateAcmeAccountResponse>> => {
const profile = await validateAcmeProfile(profileId); const profile = await validateAcmeProfile(profileId);
if (!externalAccountBinding) { 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"); const publicKeyThumbprint = await calculateJwkThumbprint(jwk, "sha256");
@@ -363,26 +370,28 @@ export const pkiAcmeServiceFactory = ({
return { eabPayload: result.payload, eabProtectedHeader: result.protectedHeader }; return { eabPayload: result.payload, eabProtectedHeader: result.protectedHeader };
} catch (error) { } catch (error) {
if (error instanceof errors.JWSSignatureVerificationFailed) { 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"); 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!; const { alg: eabAlg, kid: eabKid } = eabProtectedHeader!;
if (!["HS256", "HS384", "HS512"].includes(eabAlg!)) { 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 // Make sure the KID in the EAB payload matches the profile ID
if (eabKid !== 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 // Make sure the URL matches the expected URL
const url = eabProtectedHeader!.url!; const url = eabProtectedHeader!.url!;
if (url !== buildUrl(profile.id, "/new-account")) { 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 // 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. // 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. // 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 order = await acmeOrderDAL.transaction(async (tx) => {
const account = (await acmeAccountDAL.findByProjectIdAndAccountId(profileId, accountId))!; const account = (await acmeAccountDAL.findByProjectIdAndAccountId(profileId, accountId))!;
const createdOrder = await acmeOrderDAL.create( const createdOrder = await acmeOrderDAL.create(
@@ -497,10 +521,10 @@ export const pkiAcmeServiceFactory = ({
const authorizations: TPkiAcmeAuths[] = await Promise.all( const authorizations: TPkiAcmeAuths[] = await Promise.all(
payload.identifiers.map(async (identifier) => { payload.identifiers.map(async (identifier) => {
if (identifier.type !== AcmeIdentifierType.DNS) { 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)) { 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( const auth = await acmeAuthDAL.create(
{ {
@@ -617,11 +641,12 @@ export const pkiAcmeServiceFactory = ({
notAfter: finalizingOrder.notAfter ? new Date(finalizingOrder.notAfter) : undefined, notAfter: finalizingOrder.notAfter ? new Date(finalizingOrder.notAfter) : undefined,
validity: !finalizingOrder.notAfter 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 // 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 is not used if notAfter is provided
({ ttl: "0" } as const), ({ ttl: "0d" } as const),
enrollmentType: EnrollmentType.ACME enrollmentType: EnrollmentType.ACME
}); });
// TODO: associate the certificate with the order // TODO: associate the certificate with the order
@@ -647,9 +672,9 @@ export const pkiAcmeServiceFactory = ({
logger.error(exp, "Failed to sign certificate"); logger.error(exp, "Failed to sign certificate");
// TODO: audit log the error // TODO: audit log the error
if (exp instanceof BadRequestError) { if (exp instanceof BadRequestError) {
errorToReturn = new AcmeBadCSRError({ detail: `Invalid CSR: ${exp.message}` }); errorToReturn = new AcmeBadCSRError({ message: `Invalid CSR: ${exp.message}` });
} else { } else {
errorToReturn = new AcmeServerInternalError({ detail: "Failed to sign certificate with internal error" }); errorToReturn = new AcmeServerInternalError({ message: "Failed to sign certificate with internal error" });
} }
} }
return { return {
+1 -2
View File
@@ -252,8 +252,7 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider
error: error.name, error: error.name,
status: error.status, status: error.status,
type: `urn:ietf:params:acme:error:${error.type}`, type: `urn:ietf:params:acme:error:${error.type}`,
detail: error.detail, detail: error.message
message: error.message
// TODO: add subproblems if they exist // TODO: add subproblems if they exist
}); });
} else { } else {
+84
View File
@@ -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