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

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
.gitignore vendored
View File

@@ -71,5 +71,6 @@ frontend-build
cli/infisical-merge
cli/test/infisical-merge
/backend/binary
backend/bdd/.bdd-infisical-bootstrap-result.json
/npm/bin

View File

@@ -1,26 +1,200 @@
import json
import os
import pathlib
import httpx
from behave.runner import Context
from dotenv import load_dotenv
from faker import Faker
import logging
load_dotenv()
logger = logging.getLogger(__name__)
BASE_URL = os.environ.get("INFISICAL_API_URL", "http://localhost:8080")
PROJECT_ID = os.environ.get("PROJECT_ID")
CERT_CA_ID = os.environ.get("CERT_CA_ID")
CERT_TEMPLATE_ID = os.environ.get("CERT_TEMPLATE_ID")
AUTH_TOKEN = os.environ.get("INFISICAL_TOKEN")
BOOTSTRAP_INFISICAL = int(os.environ.get("BOOTSTRAP_INFISICAL", 0))
# Called mostly from a CI to setup the new Infisical instance to get it ready for BDD tests
def bootstrap_infisical(context: Context):
bootstrap_result_file = pathlib.Path.cwd() / ".bdd-infisical-bootstrap-result.json"
if bootstrap_result_file.exists():
logger.info(
"Bootstrap result file exists at %s, loading it now", bootstrap_result_file
)
return json.loads(bootstrap_result_file.read_text())
faker = Faker()
with httpx.Client(base_url=BASE_URL) as client:
resp = client.post(
"/api/v1/admin/signup",
json={
"email": f"{faker.user_name()}@infisical.com",
"password": faker.password(),
"firstName": faker.first_name(),
"lastName": faker.last_name(),
},
)
resp.raise_for_status()
body = resp.json()
org = body["organization"]
user = body["user"]
temp_token = body["token"]
resp = client.post(
"/api/v3/auth/select-organization",
headers={"Authorization": f"Bearer {temp_token}"},
json={"organizationId": org["id"]},
)
resp.raise_for_status()
body = resp.json()
temp_token = body["token"]
resp = client.post(
"/api/v1/auth/token",
headers={"Authorization": f"Bearer {temp_token}"},
json={},
)
resp.raise_for_status()
body = resp.json()
auth_token = body["token"]
headers = dict(authorization=f"Bearer {auth_token}")
project_slug = faker.slug()
resp = client.post(
"/api/v1/projects",
headers=headers,
json={
"projectName": project_slug,
"projectDescription": faker.paragraph(),
"template": "default",
"type": "cert-manager",
},
)
resp.raise_for_status()
body = resp.json()
project = body["project"]
ca_slug = faker.slug()
resp = client.post(
"/api/v1/pki/ca/internal",
headers=headers,
json={
"projectId": project["id"],
"name": ca_slug,
"type": "internal",
"status": "active",
"enableDirectIssuance": True,
"configuration": {
"type": "root",
"organization": "Infisican Inc",
"ou": "",
"country": "",
"province": "",
"locality": "",
"commonName": "",
"notAfter": "2035-11-07",
"maxPathLength": -1,
"keyAlgorithm": "RSA_2048",
},
},
)
resp.raise_for_status()
body = resp.json()
ca = body
cert_template_slug = faker.slug()
resp = client.post(
"/api/v2/certificate-templates",
headers=headers,
json={
"projectId": project["id"],
"name": cert_template_slug,
"description": "",
"subject": [{"type": "common_name", "allowed": ["*"]}],
"sans": [],
"keyUsages": {
"required": [],
"allowed": [
"digital_signature",
"non_repudiation",
"key_encipherment",
"data_encipherment",
"key_agreement",
"key_cert_sign",
"crl_sign",
"encipher_only",
"decipher_only",
],
},
"extendedKeyUsages": {
"required": [],
"allowed": [
"client_auth",
"server_auth",
"code_signing",
"email_protection",
"ocsp_signing",
"time_stamping",
],
},
"algorithms": {
"signature": [
"SHA256-RSA",
"SHA512-RSA",
"SHA384-ECDSA",
"SHA384-RSA",
"SHA256-ECDSA",
"SHA512-ECDSA",
],
"keyAlgorithm": [
"RSA-2048",
"RSA-4096",
"ECDSA-P384",
"RSA-3072",
"ECDSA-P256",
"ECDSA-P521",
],
},
"validity": {"max": "365d"},
},
)
resp.raise_for_status()
body = resp.json()
cert_template = body["certificateTemplate"]
bootstrap_result = dict(
org=org,
user=user,
project=project,
ca=ca,
cert_template=cert_template,
auth_token=auth_token,
)
bootstrap_result_file.write_text(json.dumps(bootstrap_result))
return bootstrap_result
def before_all(context: Context):
context.vars = {
"BASE_URL": BASE_URL,
"PROJECT_ID": PROJECT_ID,
"CERT_CA_ID": CERT_CA_ID,
"CERT_TEMPLATE_ID": CERT_TEMPLATE_ID,
"AUTH_TOKEN": AUTH_TOKEN,
}
context.http_client = httpx.Client(
base_url=BASE_URL, # headers={"Authorization": f"Bearer {AUTH_TOKEN}"}
)
if BOOTSTRAP_INFISICAL:
details = bootstrap_infisical(context)
context.vars = {
"BASE_URL": BASE_URL,
"PROJECT_ID": details["project"]["id"],
"CERT_CA_ID": details["ca"]["id"],
"CERT_TEMPLATE_ID": details["cert_template"]["id"],
"AUTH_TOKEN": details["auth_token"],
}
else:
context.vars = {
"BASE_URL": BASE_URL,
"PROJECT_ID": PROJECT_ID,
"CERT_CA_ID": CERT_CA_ID,
"CERT_TEMPLATE_ID": CERT_TEMPLATE_ID,
"AUTH_TOKEN": AUTH_TOKEN,
}
context.http_client = httpx.Client(base_url=BASE_URL)

View File

@@ -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 |

View File

@@ -2,5 +2,53 @@ Feature: Account
Scenario: Create a new account
Given I have an ACME cert profile as "acme_profile"
When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
And the value acme_account.uri with jq "." should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/(.+)
Scenario: Find an existing account
Given I have an ACME cert profile as "acme_profile"
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
And I memorize acme_account.uri as account_uri
And I find the existing ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
And the value acme_account.uri should be equal to "{account_uri}"
Scenario: Create a new account without EAB
Given I have an ACME cert profile as "acme_profile"
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
Then I register a new ACME account with email fangpen@infisical.com without EAB
And the value error with jq ".type" should be equal to "urn:ietf:params:acme:error:externalAccountRequired"
Scenario Outline: Scenario: Create a new account with bad EAB credentials
Given I have an ACME cert profile as "acme_profile"
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "<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 |

View File

@@ -2,8 +2,7 @@ Feature: Authorization
Scenario: Get authorization
Given I have an ACME cert profile as "acme_profile"
When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory
# # TODO: make it I have an account already instead?
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
When I create certificate signing request as csr
Then I add names to certificate signing request csr
@@ -13,11 +12,11 @@ Feature: Authorization
}
"""
Then I create a RSA private key pair as cert_key
Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format
Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order
Then the value order.authorizations[0].uri with jq "." should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/(.+)
Then the value order.authorizations[0].body with jq ".status" should be equal to "pending"
Then the value order.authorizations[0].body with jq ".challenges | map(pick(.type, .status)) | sort_by(.type)" should be equal to json
And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format
And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order
And the value order.authorizations[0].uri with jq "." should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/(.+)
And the value order.authorizations[0].body with jq ".status" should be equal to "pending"
And the value order.authorizations[0].body with jq ".challenges | map(pick(.type, .status)) | sort_by(.type)" should be equal to json
"""
[
{
@@ -26,8 +25,8 @@ Feature: Authorization
}
]
"""
Then the value order.authorizations[0].body with jq ".challenges | map(.status) | sort" should be equal to ["pending"]
Then the value order.authorizations[0].body with jq ".identifier" should be equal to json
And the value order.authorizations[0].body with jq ".challenges | map(.status) | sort" should be equal to ["pending"]
And the value order.authorizations[0].body with jq ".identifier" should be equal to json
"""
{
"type": "dns",

View File

@@ -2,8 +2,8 @@ Feature: ACME Cert Profile
Scenario: Create a cert profile
Given I make a random slug as profile_slug
Given I use AUTH_TOKEN for authentication
When I send a POST request to "/api/v1/pki/certificate-profiles" with JSON payload
And I use AUTH_TOKEN for authentication
When I send a "POST" request to "/api/v1/pki/certificate-profiles" with JSON payload
"""
{
"projectId": "{PROJECT_ID}",
@@ -16,16 +16,16 @@ Feature: ACME Cert Profile
}
"""
Then the value response.status_code should be equal to 200
Then the value response with jq ".certificateProfile.id" should be present
Then the value response with jq ".certificateProfile.slug" should be equal to "{profile_slug}"
Then the value response with jq ".certificateProfile.caId" should be equal to "{CERT_CA_ID}"
Then the value response with jq ".certificateProfile.certificateTemplateId" should be equal to "{CERT_TEMPLATE_ID}"
Then the value response with jq ".certificateProfile.enrollmentType" should be equal to "acme"
And the value response with jq ".certificateProfile.id" should be present
And the value response with jq ".certificateProfile.slug" should be equal to "{profile_slug}"
And the value response with jq ".certificateProfile.caId" should be equal to "{CERT_CA_ID}"
And the value response with jq ".certificateProfile.certificateTemplateId" should be equal to "{CERT_TEMPLATE_ID}"
And the value response with jq ".certificateProfile.enrollmentType" should be equal to "acme"
Scenario: Reveal EAB secret
Given I make a random slug as profile_slug
Given I use AUTH_TOKEN for authentication
When I send a POST request to "/api/v1/pki/certificate-profiles" with JSON payload
And I use AUTH_TOKEN for authentication
When I send a "POST" request to "/api/v1/pki/certificate-profiles" with JSON payload
"""
{
"projectId": "{PROJECT_ID}",
@@ -39,11 +39,11 @@ Feature: ACME Cert Profile
"""
Then the value response.status_code should be equal to 200
And I memorize response with jq ".certificateProfile.id" as profile_id
When I send a GET request to "/api/v1/pki/certificate-profiles/{profile_id}/acme/eab-secret/reveal"
When I send a "GET" request to "/api/v1/pki/certificate-profiles/{profile_id}/acme/eab-secret/reveal"
Then the value response.status_code should be equal to 200
Then the value response with jq ".eabKid" should be equal to "{profile_id}"
Then the value response with jq ".eabSecret" should be present
And the value response with jq ".eabKid" should be equal to "{profile_id}"
And the value response with jq ".eabSecret" should be present
And I memorize response with jq ".eabKid" as eab_kid
And I memorize response with jq ".eabSecret" as eab_secret
When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{profile_id}/directory
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{profile_id}/directory"
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{eab_kid}" with secret "{eab_secret}" as acme_account

View File

@@ -2,8 +2,7 @@ Feature: Challenge
Scenario: Validate challenge
Given I have an ACME cert profile as "acme_profile"
When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory
# # TODO: make it I have an account already instead?
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
When I create certificate signing request as csr
Then I add names to certificate signing request csr
@@ -12,12 +11,11 @@ Feature: Challenge
"COMMON_NAME": "localhost"
}
"""
Then I create a RSA private key pair as cert_key
Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format
Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order
Then I select challenge with type http-01 for domain localhost from order at order as challenge
Then I serve challenge response for challenge at localhost
Then I tell ACME server that challenge is ready to be verified
Then I poll and finalize the ACME order order as finalized_order
Then the value finalized_order.body with jq ".status" should be equal to "valid"
# TODO: check the fullchain pem content of the order
And I create a RSA private key pair as cert_key
And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format
And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order
And I select challenge with type http-01 for domain localhost from order at order as challenge
And I serve challenge response for challenge at localhost
And I tell ACME server that challenge is ready to be verified
And I poll and finalize the ACME order order as finalized_order
And the value finalized_order.body with jq ".status" should be equal to "valid"

View File

@@ -2,13 +2,13 @@ Feature: Directory
Scenario: Get the directory of ACME service urls
Given I have an ACME cert profile as "acme_profile"
When I send a GET request to "/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
When I send a "GET" request to "/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
Then the response status code should be "200"
Then the response body should match JSON value
"""
{
"newNonce": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-nonce",
"newAccount": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-account",
"newOrder": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order"
}
"""
And the response body should match JSON value
"""
{
"newNonce": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-nonce",
"newAccount": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-account",
"newOrder": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order"
}
"""

View File

@@ -2,6 +2,106 @@ Feature: Nonce
Scenario: Generate a new nonce
Given I have an ACME cert profile as "acme_profile"
When I send a HEAD request to "/api/v1/pki/acme/profiles/{acme_profile.id}/new-nonce"
When I send a "HEAD" request to "/api/v1/pki/acme/profiles/{acme_profile.id}/new-nonce"
Then the response status code should be "200"
Then the response header "Replay-Nonce" should contains non-empty value
And the response header "Replay-Nonce" should contains non-empty value
Scenario Outline: Send a bad nonce to account endpoints
Given I have an ACME cert profile as "acme_profile"
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
And I memorize acme_account.uri with jq "capture("/(?<id>[^/]+)$") | .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} |

View File

@@ -2,8 +2,7 @@ Feature: Order
Scenario: Create a new order
Given I have an ACME cert profile as "acme_profile"
When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory
# # TODO: make it I have an account already instead?
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
When I create certificate signing request as csr
Then I add names to certificate signing request csr
@@ -13,18 +12,17 @@ Feature: Order
}
"""
Then I create a RSA private key pair as cert_key
Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format
Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order
Then the value order.uri with jq "." should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+)
Then the value order.body with jq ".status" should be equal to "pending"
Then the value order.body with jq ".identifiers" should be equal to [{"type": "dns", "value": "localhost"}]
Then the value order.body with jq ".finalize" should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+)/finalize
Then the value order.body with jq "all(.authorizations[]; startswith("{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/"))" should be equal to true
And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format
And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order
And the value order.uri with jq "." should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+)
And the value order.body with jq ".status" should be equal to "pending"
And the value order.body with jq ".identifiers" should be equal to [{"type": "dns", "value": "localhost"}]
And the value order.body with jq ".finalize" should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+)/finalize
And the value order.body with jq "all(.authorizations[]; startswith("{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/"))" should be equal to true
Scenario: Create a new order with SANs
Given I have an ACME cert profile as "acme_profile"
When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory
# # TODO: make it I have an account already instead?
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
When I create certificate signing request as csr
Then I add names to certificate signing request csr
@@ -33,17 +31,17 @@ Feature: Order
"COMMON_NAME": "localhost"
}
"""
Then I add subject alternative name to certificate signing request csr
And I add subject alternative name to certificate signing request csr
"""
[
"example.com",
"infisical.com"
]
"""
Then I create a RSA private key pair as cert_key
Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format
Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order
Then the value order.body with jq ".identifiers | sort_by(.value)" should be equal to json
And I create a RSA private key pair as cert_key
And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format
And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order
And the value order.body with jq ".identifiers | sort_by(.value)" should be equal to json
"""
[
{"type": "dns", "value": "example.com"},
@@ -54,8 +52,7 @@ Feature: Order
Scenario: Fetch an order
Given I have an ACME cert profile as "acme_profile"
When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory
# # TODO: make it I have an account already instead?
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
When I create certificate signing request as csr
Then I add names to certificate signing request csr
@@ -65,10 +62,81 @@ Feature: Order
}
"""
Then I create a RSA private key pair as cert_key
Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format
Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order
Then I send an ACME post-as-get to order.uri as fetched_order
Then the value fetched_order with jq ".status" should be equal to "pending"
Then the value fetched_order with jq ".identifiers" should be equal to [{"type": "dns", "value": "localhost"}]
Then the value fetched_order with jq ".finalize" should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+)/finalize
Then the value fetched_order with jq "all(.authorizations[]; startswith("{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/"))" should be equal to true
And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format
And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order
And I send an ACME post-as-get to order.uri as fetched_order
And the value fetched_order with jq ".status" should be equal to "pending"
And the value fetched_order with jq ".identifiers" should be equal to [{"type": "dns", "value": "localhost"}]
And the value fetched_order with jq ".finalize" should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+)/finalize
And the value fetched_order with jq "all(.authorizations[]; startswith("{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/"))" should be equal to true
Scenario Outline: Create an order with invalid identifier types
Given I have an ACME cert profile as "acme_profile"
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
And I peak and memorize the next nonce as nonce
When I send a raw ACME request to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order"
"""
{
"protected": {
"alg": "RS256",
"nonce": "{nonce}",
"url": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order",
"kid": "{acme_account.uri}"
},
"payload": {
"identifiers": [
{ "type": "<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 |

View File

@@ -1,9 +1,10 @@
import json
import logging
import os
import re
import threading
import urllib.parse
import acme.client
import httpx
import jq
import requests
@@ -12,12 +13,14 @@ from faker import Faker
from acme import client
from acme import messages
from acme import standalone
from acme.jws import Signature
from behave.runner import Context
from behave import given
from behave import when
from behave import then
from josepy.jwk import JWKRSA
from josepy import JSONObjectWithFields
from josepy import json_util
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography import x509
@@ -134,12 +137,39 @@ def step_impl(context: Context, faker_type: str, var_name: str):
@given('I have an ACME cert profile as "{profile_var}"')
def step_impl(context: Context, profile_var: str):
# TODO: Fixed value for now, just to make test much easier,
# we should call infisical API to create such profile instead
# in the future
profile_id = os.getenv("PROFILE_ID")
kid = profile_id
secret = os.getenv("EAB_SECRET")
profile_id = context.vars.get("PROFILE_ID")
secret = context.vars.get("EAB_SECRET")
if profile_id is not None and secret is not None:
kid = profile_id
else:
profile_slug = faker.slug()
jwt_token = context.vars["AUTH_TOKEN"]
response = context.http_client.post(
"/api/v1/pki/certificate-profiles",
headers=dict(authorization="Bearer {}".format(jwt_token)),
json={
"projectId": context.vars["PROJECT_ID"],
"slug": profile_slug,
"description": "ACME Profile created by BDD test",
"enrollmentType": "acme",
"caId": context.vars["CERT_CA_ID"],
"certificateTemplateId": context.vars["CERT_TEMPLATE_ID"],
"acmeConfig": {},
},
)
response.raise_for_status()
resp_json = response.json()
profile_id = resp_json["certificateProfile"]["id"]
kid = profile_id
response = context.http_client.get(
f"/api/v1/pki/certificate-profiles/{profile_id}/acme/eab-secret/reveal",
headers=dict(authorization="Bearer {}".format(jwt_token)),
)
response.raise_for_status()
resp_json = response.json()
secret = resp_json["eabSecret"]
context.vars[profile_var] = AcmeProfile(
profile_id,
eab_kid=kid,
@@ -152,7 +182,7 @@ def step_impl(context: Context, token_var: str):
context.auth_token = eval_var(context, token_var)
@when('I send a {method} request to "{url}"')
@when('I send a "{method}" request to "{url}"')
def step_impl(context: Context, method: str, url: str):
logger.debug("Sending %s request to %s", method, url)
response = context.http_client.request(
@@ -166,7 +196,7 @@ def step_impl(context: Context, method: str, url: str):
pass
@when('I send a {method} request to "{url}" with JSON payload')
@when('I send a "{method}" request to "{url}" with JSON payload')
def step_impl(context: Context, method: str, url: str):
json_payload = json.loads(context.text)
json_payload = replace_vars(json_payload, context.vars)
@@ -187,23 +217,34 @@ def step_impl(context: Context, method: str, url: str):
logger.debug("Response JSON payload: %r", response.json())
@when("I have an ACME client connecting to {url}")
def step_impl(context: Context, url: str):
private_key = rsa.generate_private_key(
public_exponent=ACC_KEY_PUBLIC_EXPONENT, key_size=ACC_KEY_BITS
)
pem_bytes = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
acc_jwk = JWKRSA.load(pem_bytes)
def create_acme_client(context: Context, url: str, acc_jwk: JWKRSA | None = None):
if acc_jwk is None:
private_key = rsa.generate_private_key(
public_exponent=ACC_KEY_PUBLIC_EXPONENT, key_size=ACC_KEY_BITS
)
pem_bytes = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
acc_jwk = JWKRSA.load(pem_bytes)
net = client.ClientNetwork(acc_jwk)
directory_url = url.format(**context.vars)
directory = client.ClientV2.get_directory(directory_url, net)
context.acme_client = client.ClientV2(directory, net=net)
@when('I have an ACME client connecting to "{url}"')
def step_impl(context: Context, url: str):
create_acme_client(context, url)
@when('I have an ACME client connecting to "{url}" with the key pair from {client_var}')
def step_impl(context: Context, url: str, client_var: str):
another_client = eval_var(context, client_var, as_json=False)
create_acme_client(context, url, acc_jwk=another_client.net.key)
@then('the response status code should be "{expected_status_code:d}"')
def step_impl(context: Context, expected_status_code: int):
assert context.vars["response"].status_code == expected_status_code, (
@@ -228,24 +269,122 @@ def step_impl(context: Context):
assert payload == replaced, f"{payload} != {replaced}"
@then(
'I register a new ACME account with email {email} and EAB key id "{kid}" with secret "{secret}" as {account_var}'
)
def step_impl(context: Context, email: str, kid: str, secret: str, account_var: str):
@when('I use a different new-account URL "{url}" for EAB signature')
def step_impl(context: Context, url: str):
context.alt_eab_url = replace_vars(url, context.vars)
def register_account_with_eab(
context: Context,
email: str,
kid: str,
secret: str,
account_var: str,
only_return_existing: bool = False,
):
acme_client = context.acme_client
account_public_key = acme_client.net.key.public_key()
if hasattr(context, "alt_eab_url"):
eab_directory = messages.Directory.from_json(
{"newAccount": context.alt_eab_url}
)
else:
eab_directory = acme_client.directory
eab = messages.ExternalAccountBinding.from_data(
account_public_key=account_public_key,
kid=replace_vars(kid, context.vars),
hmac_key=replace_vars(secret, context.vars),
directory=acme_client.directory,
directory=eab_directory,
hmac_alg="HS256",
)
registration = messages.NewRegistration.from_data(
email=email,
external_account_binding=eab,
only_return_existing=only_return_existing,
)
context.vars[account_var] = acme_client.new_account(registration)
try:
context.vars[account_var] = acme_client.new_account(registration)
except Exception as exp:
context.vars["error"] = exp
@then(
'I register a new ACME account with email {email} and EAB key id "{kid}" with secret "{secret}" as {account_var}'
)
def step_impl(context: Context, email: str, kid: str, secret: str, account_var: str):
register_account_with_eab(
context=context, email=email, kid=kid, secret=secret, account_var=account_var
)
@then(
'I find the existing ACME account with email {email} and EAB key id "{kid}" with secret "{secret}" as {account_var}'
)
def step_impl(context: Context, email: str, kid: str, secret: str, account_var: str):
register_account_with_eab(
context=context,
email=email,
kid=kid,
secret=secret,
account_var=account_var,
only_return_existing=True,
)
@then("I register a new ACME account with email {email} without EAB")
def step_impl(context: Context, email: str):
acme_client = context.acme_client
registration = messages.NewRegistration.from_data(
email=email,
)
try:
context.vars["error"] = acme_client.new_account(registration)
except Exception as exp:
context.vars["error"] = exp
def send_raw_acme_req(context: Context, url: str):
acme_client = context.acme_client
content = json.loads(context.text)
protected = replace_vars(content["protected"], context.vars)
alg = acme_client.net.alg
if "raw_payload" in content:
encoded_payload = content["raw_payload"].encode("utf-8")
elif "payload" in content:
payload = (
replace_vars(content["payload"], context.vars)
if "payload" in content
else None
)
encoded_payload = json.dumps(payload).encode() if payload is not None else b""
else:
encoded_payload = b""
protected_headers = json.dumps(protected)
signature = alg.sign(
key=acme_client.net.key.key,
msg=Signature._msg(protected_headers, encoded_payload),
)
jws = json.dumps(
{
"protected": json_util.encode_b64jose(protected_headers.encode()),
"payload": json_util.encode_b64jose(encoded_payload),
"signature": json_util.encode_b64jose(signature),
}
)
base_url = context.vars["BASE_URL"]
actual_url = urllib.parse.urljoin(base_url, replace_vars(url, context.vars))
response = acme_client.net._send_request(
"POST",
actual_url,
data=jws,
headers={"Content-Type": acme.client.ClientNetwork.JOSE_CONTENT_TYPE},
)
context.vars["response"] = response
@when('I send a raw ACME request to "{url}"')
def step_impl(context: Context, url: str):
send_raw_acme_req(context, url)
@then(
@@ -384,10 +523,17 @@ def step_impl(context: Context, var_path: str):
@then("the value {var_path} should be equal to {expected}")
def step_impl(context: Context, var_path: str, expected: str):
value = eval_var(context, var_path)
expected_value = json.loads(expected)
expected_value = replace_vars(json.loads(expected), context.vars)
assert value == expected_value, f"{value!r} does not match {expected_value!r}"
@then("the value {var_path} should not be equal to {expected}")
def step_impl(context: Context, var_path: str, expected: str):
value = eval_var(context, var_path)
expected_value = replace_vars(json.loads(expected), context.vars)
assert value != expected_value, f"{value!r} does match {expected_value!r}"
@then('I memorize {var_path} with jq "{jq_query}" as {var_name}')
def step_impl(context: Context, var_path: str, jq_query, var_name: str):
_, value = apply_value_with_jq(
@@ -398,6 +544,19 @@ def step_impl(context: Context, var_path: str, jq_query, var_name: str):
context.vars[var_name] = value
@then("I peak and memorize the next nonce as {var_name}")
def step_impl(context: Context, var_name: str):
acme_client = context.acme_client
context.vars[var_name] = json_util.encode_b64jose(list(acme_client.net._nonces)[0])
@then("I put away current ACME client as {var_name}")
def step_impl(context: Context, var_name: str):
acme_client = context.acme_client
del context.acme_client
context.vars[var_name] = acme_client
@then("I memorize {var_path} as {var_name}")
def step_impl(context: Context, var_path: str, var_name: str):
value = eval_var(context, var_path)

View File

@@ -44,6 +44,7 @@
"test:e2e": "vitest run -c vitest.e2e.config.mts --bail=1",
"test:e2e-watch": "vitest -c vitest.e2e.config.mts --bail=1",
"test:e2e-coverage": "vitest run --coverage -c vitest.e2e.config.mts",
"test:bdd": "cd bdd && uv run behave",
"generate:component": "tsx ./scripts/create-backend-file.ts",
"generate:schema": "tsx ./scripts/generate-schema-types.ts && eslint --fix --ext ts ./src/db/schemas",
"auditlog-migration:latest": "node ./dist/db/rename-migrations-to-mjs.mjs && knex --knexfile ./dist/db/auditlog-knexfile.mjs --client pg migrate:latest",

View File

@@ -261,7 +261,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => {
req
});
if (payload !== "") {
throw new AcmeMalformedError({ detail: "Payload should be empty" });
throw new AcmeMalformedError({ message: "Payload should be empty" });
}
return sendAcmeResponse(
res,
@@ -373,7 +373,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => {
req
});
if (payload !== "") {
throw new AcmeMalformedError({ detail: "Payload should be empty" });
throw new AcmeMalformedError({ message: "Payload should be empty" });
}
res.type("application/pem-certificate-chain");
return sendAcmeResponse(
@@ -407,7 +407,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => {
handler: async (req, res) => {
const { profileId, accountId, payload } = await validateExistingAccount({ req });
if (payload !== "") {
throw new AcmeMalformedError({ detail: "Payload should be empty" });
throw new AcmeMalformedError({ message: "Payload should be empty" });
}
return sendAcmeResponse(
res,

View File

@@ -35,7 +35,7 @@ export enum AcmeErrorType {
export interface IAcmeError {
type: AcmeErrorType;
detail: string;
message: string;
status: number;
subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>;
}
@@ -43,7 +43,7 @@ export interface IAcmeError {
export class AcmeError extends Error implements IAcmeError {
type: AcmeErrorType;
detail: string;
message: string;
status: number;
@@ -53,22 +53,20 @@ export class AcmeError extends Error implements IAcmeError {
constructor({
type,
detail,
message,
status,
subproblems,
error,
message
error
}: {
type: AcmeErrorType;
detail: string;
message: string;
status: number;
subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>;
error?: unknown;
message?: string;
}) {
super(message || detail);
super(message);
this.type = type;
this.detail = detail;
this.message = message;
this.status = status;
this.subproblems = subproblems;
this.error = error;
@@ -78,7 +76,7 @@ export class AcmeError extends Error implements IAcmeError {
toAcmeResponse(): IAcmeError {
return {
type: this.type,
detail: this.detail,
message: this.message,
status: this.status,
subproblems: this.subproblems
};
@@ -90,20 +88,17 @@ export class AcmeError extends Error implements IAcmeError {
*/
export class AcmeMalformedError extends AcmeError {
constructor({
detail = "The request message was malformed",
error,
message
message = "The request message was malformed",
error
}: {
detail?: string;
error?: unknown;
message?: string;
error?: unknown;
} = {}) {
super({
type: AcmeErrorType.Malformed,
detail,
message,
status: 400,
error,
message
error
});
this.name = "AcmeMalformedError";
}
@@ -114,20 +109,17 @@ export class AcmeMalformedError extends AcmeError {
*/
export class AcmeUnauthorizedError extends AcmeError {
constructor({
detail = "The client lacks sufficient authorization",
error,
message
message = "The client lacks sufficient authorization",
error
}: {
detail?: string;
error?: unknown;
message?: string;
error?: unknown;
} = {}) {
super({
type: AcmeErrorType.Unauthorized,
detail,
message,
status: 403,
error,
message
error
});
this.name = "AcmeUnauthorizedError";
}
@@ -139,20 +131,17 @@ export class AcmeUnauthorizedError extends AcmeError {
*/
export class AcmeAccountDoesNotExistError extends AcmeError {
constructor({
detail = "The request specified an account that does not exist",
error,
message
message = "The request specified an account that does not exist",
error
}: {
detail?: string;
error?: unknown;
message?: string;
error?: unknown;
} = {}) {
super({
type: AcmeErrorType.AccountDoesNotExist,
detail,
status: 400,
error,
message
message,
status: 404,
error
});
this.name = "AcmeAccountDoesNotExistError";
}
@@ -163,20 +152,17 @@ export class AcmeAccountDoesNotExistError extends AcmeError {
*/
export class AcmeBadNonceError extends AcmeError {
constructor({
detail = "The client sent an unacceptable anti-replay nonce",
error,
message
message = "The client sent an unacceptable anti-replay nonce",
error
}: {
detail?: string;
error?: unknown;
message?: string;
error?: unknown;
} = {}) {
super({
type: AcmeErrorType.BadNonce,
detail,
message,
status: 400,
error,
message
error
});
this.name = "AcmeBadNonceError";
}
@@ -187,20 +173,17 @@ export class AcmeBadNonceError extends AcmeError {
*/
export class AcmeBadSignatureAlgorithmError extends AcmeError {
constructor({
detail = "The signature algorithm is invalid",
error,
message
message = "The signature algorithm is invalid",
error
}: {
detail?: string;
error?: unknown;
message?: string;
error?: unknown;
} = {}) {
super({
type: AcmeErrorType.BadSignatureAlgorithm,
detail,
message,
status: 401,
error,
message
error
});
this.name = "AcmeBadSignatureAlgorithmError";
}
@@ -211,20 +194,17 @@ export class AcmeBadSignatureAlgorithmError extends AcmeError {
*/
export class AcmeBadPublicKeyError extends AcmeError {
constructor({
detail = "The public key is not acceptable",
error,
message
message = "The public key is not acceptable",
error
}: {
detail?: string;
error?: unknown;
message?: string;
error?: unknown;
} = {}) {
super({
type: AcmeErrorType.BadPublicKey,
detail,
message,
status: 400,
error,
message
error
});
this.name = "AcmeBadPublicKeyError";
}
@@ -235,20 +215,17 @@ export class AcmeBadPublicKeyError extends AcmeError {
*/
export class AcmeBadCsrError extends AcmeError {
constructor({
detail = "The CSR is unacceptable",
error,
message
message = "The CSR is unacceptable",
error
}: {
detail?: string;
error?: unknown;
message?: string;
error?: unknown;
} = {}) {
super({
type: AcmeErrorType.BadCsr,
detail,
message,
status: 400,
error,
message
error
});
this.name = "AcmeBadCsrError";
}
@@ -260,20 +237,17 @@ export class AcmeBadCsrError extends AcmeError {
*/
export class AcmeBadRevocationReasonError extends AcmeError {
constructor({
detail = "The revocation reason provided is not allowed",
error,
message
message = "The revocation reason provided is not allowed",
error
}: {
detail?: string;
error?: unknown;
message?: string;
error?: unknown;
} = {}) {
super({
type: AcmeErrorType.BadRevocationReason,
detail,
message,
status: 400,
error,
message
error
});
this.name = "AcmeBadRevocationReasonError";
}
@@ -284,20 +258,17 @@ export class AcmeBadRevocationReasonError extends AcmeError {
*/
export class AcmeRateLimitedError extends AcmeError {
constructor({
detail = "The client has exceeded a rate limit",
error,
message
message = "The client has exceeded a rate limit",
error
}: {
detail?: string;
error?: unknown;
message?: string;
error?: unknown;
} = {}) {
super({
type: AcmeErrorType.RateLimited,
detail,
message,
status: 429,
error,
message
error
});
this.name = "AcmeRateLimitedError";
}
@@ -309,23 +280,20 @@ export class AcmeRateLimitedError extends AcmeError {
*/
export class AcmeRejectedIdentifierError extends AcmeError {
constructor({
detail = "The server will not issue certificates for the identifier",
message = "The server will not issue certificates for the identifier",
subproblems,
error,
message
error
}: {
detail?: string;
message?: string;
subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>;
error?: unknown;
message?: string;
} = {}) {
super({
type: AcmeErrorType.RejectedIdentifier,
detail,
message,
status: 400,
subproblems,
error,
message
error
});
this.name = "AcmeRejectedIdentifierError";
}
@@ -336,20 +304,17 @@ export class AcmeRejectedIdentifierError extends AcmeError {
*/
export class AcmeServerInternalError extends AcmeError {
constructor({
detail = "An internal error occurred",
error,
message
message = "An internal error occurred",
error
}: {
detail?: string;
error?: unknown;
message?: string;
error?: unknown;
} = {}) {
super({
type: AcmeErrorType.ServerInternal,
detail,
message,
status: 500,
error,
message
error
});
this.name = "AcmeServerInternalError";
}
@@ -360,20 +325,17 @@ export class AcmeServerInternalError extends AcmeError {
*/
export class AcmeUnsupportedContactError extends AcmeError {
constructor({
detail = "A contact URL is of an unsupported type",
error,
message
message = "A contact URL is of an unsupported type",
error
}: {
detail?: string;
error?: unknown;
message?: string;
error?: unknown;
} = {}) {
super({
type: AcmeErrorType.UnsupportedContact,
detail,
message,
status: 400,
error,
message
error
});
this.name = "AcmeUnsupportedContactError";
}
@@ -385,20 +347,17 @@ export class AcmeUnsupportedContactError extends AcmeError {
*/
export class AcmeUnsupportedIdentifierError extends AcmeError {
constructor({
detail = "An identifier is of an unsupported type",
error,
message
message = "An identifier is of an unsupported type",
error
}: {
detail?: string;
error?: unknown;
message?: string;
error?: unknown;
} = {}) {
super({
type: AcmeErrorType.UnsupportedIdentifier,
detail,
message,
status: 400,
error,
message
error
});
this.name = "AcmeUnsupportedIdentifierError";
}
@@ -412,22 +371,19 @@ export class AcmeUserActionRequiredError extends AcmeError {
instance?: string;
constructor({
detail = "Visit the instance URL and take actions specified there",
message = "Visit the instance URL and take actions specified there",
instance,
error,
message
error
}: {
detail?: string;
message?: string;
instance?: string;
error?: unknown;
message?: string;
} = {}) {
super({
type: AcmeErrorType.UserActionRequired,
detail,
message,
status: 403,
error,
message
error
});
this.instance = instance;
this.name = "AcmeUserActionRequiredError";
@@ -446,20 +402,17 @@ export class AcmeUserActionRequiredError extends AcmeError {
*/
export class AcmeIncorrectResponseError extends AcmeError {
constructor({
detail = "The response is incorrect",
error,
message
message = "The response is incorrect",
error
}: {
detail?: string;
error?: unknown;
message?: string;
error?: unknown;
} = {}) {
super({
type: AcmeErrorType.IncorrectResponse,
detail,
message,
status: 400,
error,
message
error
});
this.name = "AcmeIncorrectResponseError";
}
@@ -470,20 +423,17 @@ export class AcmeIncorrectResponseError extends AcmeError {
*/
export class AcmeConnectionError extends AcmeError {
constructor({
detail = "A connection error occurred",
error,
message
message = "A connection error occurred",
error
}: {
detail?: string;
error?: unknown;
message?: string;
error?: unknown;
} = {}) {
super({
type: AcmeErrorType.Connection,
detail,
message,
status: 400,
error,
message
error
});
this.name = "AcmeConnectionError";
}
@@ -491,20 +441,17 @@ export class AcmeConnectionError extends AcmeError {
export class AcmeDnsFailureError extends AcmeError {
constructor({
detail = "Hostname could not be resolved (DNS failure)",
error,
message
message = "Hostname could not be resolved (DNS failure)",
error
}: {
detail?: string;
error?: unknown;
message?: string;
error?: unknown;
} = {}) {
super({
type: AcmeErrorType.DNS,
detail,
message,
status: 400,
error,
message
error
});
this.name = "AcmeDnsFailureError";
}
@@ -512,20 +459,17 @@ export class AcmeDnsFailureError extends AcmeError {
export class AcmeOrderNotReadyError extends AcmeError {
constructor({
detail = "The order is not ready",
error,
message
message = "The order is not ready",
error
}: {
detail?: string;
error?: unknown;
message?: string;
error?: unknown;
} = {}) {
super({
type: AcmeErrorType.OrderNotReady,
detail,
message,
status: 403,
error,
message
error
});
this.name = "AcmeOrderNotReadyError";
}
@@ -533,20 +477,17 @@ export class AcmeOrderNotReadyError extends AcmeError {
export class AcmeBadCSRError extends AcmeError {
constructor({
detail = "The CSR is unacceptable",
error,
message
message = "The CSR is unacceptable",
error
}: {
detail?: string;
error?: unknown;
message?: string;
error?: unknown;
} = {}) {
super({
type: AcmeErrorType.BadCsr,
detail,
message,
status: 400,
error,
message
error
});
this.name = "AcmeBadCSRError";
}
@@ -554,20 +495,17 @@ export class AcmeBadCSRError extends AcmeError {
export class AcmeExternalAccountRequiredError extends AcmeError {
constructor({
detail = "External account binding is required",
error,
message
message = "External account binding is required",
error
}: {
detail?: string;
error?: unknown;
message?: string;
error?: unknown;
} = {}) {
super({
type: AcmeErrorType.ExternalAccountRequired,
detail,
message,
status: 400,
error,
message
error
});
this.name = "AcmeExternalAccountRequiredError";
}

View File

@@ -1,8 +1,7 @@
import { z } from "zod";
import { getConfig } from "@app/lib/config/env";
import { AcmeMalformedError } from "./pki-acme-errors";
import RE2 from "re2";
import { z } from "zod";
import { AcmeAccountDoesNotExistError } from "./pki-acme-errors";
export const buildUrl = (profileId: string, path: string): string => {
const appCfg = getConfig();
@@ -13,7 +12,14 @@ export const buildUrl = (profileId: string, path: string): string => {
export const extractAccountIdFromKid = (kid: string, profileId: string): string => {
const kidPrefix = buildUrl(profileId, "/accounts/");
if (!kid.startsWith(kidPrefix)) {
throw new AcmeMalformedError({ detail: "KID must start with the profile account URL" });
throw new AcmeAccountDoesNotExistError({ message: "KID must start with the profile account URL" });
}
return z.string().uuid().parse(kid.slice(kidPrefix.length));
};
export const validateDnsIdentifier = (identifier: string): boolean => {
// DNS label pattern: 1-63 chars, alphanumeric or hyphen, but not starting or ending with hyphen
const labelPattern = new RE2(/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/);
const labels = identifier.split(".");
return labels.every((label) => label.length >= 1 && label.length <= 63 && labelPattern.test(label));
};

View File

@@ -1,4 +1,3 @@
import RE2 from "re2";
import { z } from "zod";
export enum AcmeIdentifierType {
@@ -88,13 +87,8 @@ export const CreateAcmeAccountResponseSchema = z.object({
export const CreateAcmeOrderBodySchema = z.object({
identifiers: z.array(
z.object({
type: z.enum(Object.values(AcmeIdentifierType) as [string, ...string[]]),
value: z.string().refine((val) => {
// DNS label pattern: 1-63 chars, alphanumeric or hyphen, but not starting or ending with hyphen
const labelPattern = new RE2(/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/);
const labels = val.split(".");
return labels.every((label) => label.length >= 1 && label.length <= 63 && labelPattern.test(label));
}, "Invalid DNS identifier")
type: z.string(),
value: z.string()
})
),
notBefore: z.string().optional(),

View File

@@ -13,21 +13,22 @@ import { TPkiAcmeAccounts } from "@app/db/schemas/pki-acme-accounts";
import { TPkiAcmeAuths } from "@app/db/schemas/pki-acme-auths";
import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore";
import { crypto } from "@app/lib/crypto/cryptography";
import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { isPrivateIp } from "@app/lib/ip/ipRange";
import { logger } from "@app/lib/logger";
import { ActorType } from "@app/services/auth/auth-type";
import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal";
import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal";
import {
EnrollmentType,
TCertificateProfileWithConfigs
} from "@app/services/certificate-profile/certificate-profile-types";
import { TCertificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service";
import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns";
import { getConfig } from "@app/lib/config/env";
import { TPkiAcmeAccountDALFactory } from "./pki-acme-account-dal";
import { TPkiAcmeAuthDALFactory } from "./pki-acme-auth-dal";
import { TPkiAcmeChallengeDALFactory } from "./pki-acme-challenge-dal";
@@ -41,10 +42,9 @@ import {
AcmeMalformedError,
AcmeOrderNotReadyError,
AcmeServerInternalError,
AcmeUnauthorizedError,
AcmeUnsupportedIdentifierError
} from "./pki-acme-errors";
import { buildUrl, extractAccountIdFromKid } from "./pki-acme-fns";
import { buildUrl, extractAccountIdFromKid, validateDnsIdentifier } from "./pki-acme-fns";
import { TPkiAcmeOrderAuthDALFactory } from "./pki-acme-order-auth-dal";
import { TPkiAcmeOrderDALFactory } from "./pki-acme-order-dal";
import {
@@ -148,7 +148,7 @@ export const pkiAcmeServiceFactory = ({
try {
result = await flattenedVerify(rawJwsPayload, async (protectedHeader: JWSHeaderParameters | undefined) => {
if (protectedHeader === undefined) {
throw new AcmeMalformedError({ detail: "Protected header is required" });
throw new AcmeMalformedError({ message: "Protected header is required" });
}
const jwk = await getJWK(protectedHeader);
const key = await importJWK(jwk, protectedHeader.alg);
@@ -159,28 +159,35 @@ export const pkiAcmeServiceFactory = ({
throw error;
}
if (error instanceof ZodError) {
throw new AcmeMalformedError({ detail: `Invalid JWS payload: ${error.message}` });
throw new AcmeMalformedError({ message: `Invalid JWS payload: ${error.message}` });
}
if (error instanceof errors.JWSSignatureVerificationFailed) {
throw new AcmeBadPublicKeyError({ detail: "Invalid JWS payload" });
throw new AcmeBadPublicKeyError({ message: "Invalid JWS payload" });
}
logger.error(error, "Unexpected error while verifying JWS payload");
throw new AcmeServerInternalError({ detail: "Failed to verify JWS payload" });
throw new AcmeMalformedError({ message: "Failed to verify JWS payload" });
}
const { protectedHeader: rawProtectedHeader, payload: rawPayload } = result;
try {
const protectedHeader = ProtectedHeaderSchema.parse(rawProtectedHeader);
const parsedUrl = (() => {
try {
return new URL(protectedHeader.url);
} catch (error) {
throw new AcmeMalformedError({ message: "Invalid URL in the protected header" });
}
})();
// Validate the URL
if (new URL(protectedHeader.url).href !== url.href) {
throw new AcmeUnauthorizedError({ detail: "URL mismatch in the protected header" });
if (parsedUrl.href !== url.href) {
throw new AcmeMalformedError({ message: "URL mismatch in the protected header" });
}
// Consume the nonce
if (!protectedHeader.nonce) {
throw new AcmeMalformedError({ detail: "Nonce is required in the protected header" });
throw new AcmeMalformedError({ message: "Nonce is required in the protected header" });
}
const deleted = await keyStore.deleteItem(KeyStorePrefixes.PkiAcmeNonce(protectedHeader.nonce));
if (deleted !== 1) {
throw new AcmeBadNonceError({ detail: "Invalid nonce" });
throw new AcmeBadNonceError({ message: "Invalid nonce" });
}
// Parse the payload
@@ -196,10 +203,10 @@ export const pkiAcmeServiceFactory = ({
throw error;
}
if (error instanceof ZodError) {
throw new AcmeMalformedError({ detail: `Invalid JWS payload: ${error.message}` });
throw new AcmeMalformedError({ message: `Invalid JWS payload: ${error.message}` });
}
logger.error(error, "Unexpected error while parsing JWS payload");
throw new AcmeServerInternalError({ detail: "Failed to verify JWS payload" });
throw new AcmeMalformedError({ message: "Failed to verify JWS payload" });
}
};
@@ -215,7 +222,7 @@ export const pkiAcmeServiceFactory = ({
rawJwsPayload,
getJWK: async (protectedHeader) => {
if (!protectedHeader.jwk) {
throw new AcmeMalformedError({ detail: "JWK is required in the protected header" });
throw new AcmeMalformedError({ message: "JWK is required in the protected header" });
}
return protectedHeader.jwk as unknown as JsonWebKey;
},
@@ -246,18 +253,18 @@ export const pkiAcmeServiceFactory = ({
rawJwsPayload,
getJWK: async (protectedHeader) => {
if (!protectedHeader.kid) {
throw new AcmeMalformedError({ detail: "KID is required in the protected header" });
throw new AcmeMalformedError({ message: "KID is required in the protected header" });
}
const accountId = extractAccountIdFromKid(protectedHeader.kid, profileId);
if (expectedAccountId && accountId !== expectedAccountId) {
throw new NotFoundError({ message: "ACME resource not found" });
throw new AcmeAccountDoesNotExistError({ message: "ACME resource not found" });
}
const account = await acmeAccountDAL.findByProjectIdAndAccountId(profile.id, accountId);
if (!account) {
throw new AcmeAccountDoesNotExistError({ message: "ACME account not found" });
}
if (account.alg !== protectedHeader.alg) {
throw new AcmeMalformedError({ detail: "ACME account algorithm mismatch" });
throw new AcmeMalformedError({ message: "ACME account algorithm mismatch" });
}
return account.publicKey as JsonWebKey;
},
@@ -344,7 +351,7 @@ export const pkiAcmeServiceFactory = ({
}): Promise<TAcmeResponse<TCreateAcmeAccountResponse>> => {
const profile = await validateAcmeProfile(profileId);
if (!externalAccountBinding) {
throw new AcmeExternalAccountRequiredError({ detail: "External account binding is required" });
throw new AcmeExternalAccountRequiredError({ message: "External account binding is required" });
}
const publicKeyThumbprint = await calculateJwkThumbprint(jwk, "sha256");
@@ -363,26 +370,28 @@ export const pkiAcmeServiceFactory = ({
return { eabPayload: result.payload, eabProtectedHeader: result.protectedHeader };
} catch (error) {
if (error instanceof errors.JWSSignatureVerificationFailed) {
throw new AcmeMalformedError({ detail: "Invalid external account binding JWS signature" });
throw new AcmeExternalAccountRequiredError({ message: "Invalid external account binding JWS signature" });
}
logger.error(error, "Unexpected error while verifying EAB JWS signature");
throw new AcmeServerInternalError({ detail: "Failed to verify EAB JWS signature" });
throw new AcmeServerInternalError({ message: "Failed to verify EAB JWS signature" });
}
})();
const { alg: eabAlg, kid: eabKid } = eabProtectedHeader!;
if (!["HS256", "HS384", "HS512"].includes(eabAlg!)) {
throw new AcmeMalformedError({ detail: "Invalid algorithm for external account binding JWS payload" });
throw new AcmeExternalAccountRequiredError({
message: "Invalid algorithm for external account binding JWS payload"
});
}
// Make sure the KID in the EAB payload matches the profile ID
if (eabKid !== profile.id) {
throw new UnauthorizedError({ message: "External account binding KID mismatch" });
throw new AcmeExternalAccountRequiredError({ message: "External account binding KID mismatch" });
}
// Make sure the URL matches the expected URL
const url = eabProtectedHeader!.url!;
if (url !== buildUrl(profile.id, "/new-account")) {
throw new UnauthorizedError({ message: "External account binding URL mismatch" });
throw new AcmeExternalAccountRequiredError({ message: "External account binding URL mismatch" });
}
// Make sure the JWK in the EAB payload matches the one provided in the outer JWS payload
@@ -481,6 +490,21 @@ export const pkiAcmeServiceFactory = ({
// TODO: check the identifiers and see if are they even allowed for this profile.
// if not, we may be able to reject it early with an unsupportedIdentifier error.
// TODO: ideally, we should return an error with subproblems if we have multiple unsupported identifiers
if (payload.identifiers.some((identifier) => identifier.type !== AcmeIdentifierType.DNS)) {
throw new AcmeUnsupportedIdentifierError({ message: "Only DNS identifiers are supported" });
}
if (
payload.identifiers.some(
(identifier) =>
!validateDnsIdentifier(identifier.value) ||
isPrivateIp(identifier.value) ||
(!getConfig().isDevelopmentMode && identifier.value.toLowerCase() === "localhost")
)
) {
throw new AcmeUnsupportedIdentifierError({ message: "Invalid DNS identifier" });
}
const order = await acmeOrderDAL.transaction(async (tx) => {
const account = (await acmeAccountDAL.findByProjectIdAndAccountId(profileId, accountId))!;
const createdOrder = await acmeOrderDAL.create(
@@ -497,10 +521,10 @@ export const pkiAcmeServiceFactory = ({
const authorizations: TPkiAcmeAuths[] = await Promise.all(
payload.identifiers.map(async (identifier) => {
if (identifier.type !== AcmeIdentifierType.DNS) {
throw new AcmeUnsupportedIdentifierError({ detail: "Only DNS identifiers are supported" });
throw new AcmeUnsupportedIdentifierError({ message: "Only DNS identifiers are supported" });
}
if (isPrivateIp(identifier.value)) {
throw new AcmeUnsupportedIdentifierError({ detail: "Private IP addresses are not allowed" });
throw new AcmeUnsupportedIdentifierError({ message: "Private IP addresses are not allowed" });
}
const auth = await acmeAuthDAL.create(
{
@@ -617,11 +641,12 @@ export const pkiAcmeServiceFactory = ({
notAfter: finalizingOrder.notAfter ? new Date(finalizingOrder.notAfter) : undefined,
validity: !finalizingOrder.notAfter
? {
// 47 days, the default TTL comes with Let's Encrypt
// TODO: read config from the profile to get the expiration time instead
ttl: (24 * 60 * 60 * 1000).toString()
ttl: `${47}d`
}
: // ttl is not used if notAfter is provided
({ ttl: "0" } as const),
({ ttl: "0d" } as const),
enrollmentType: EnrollmentType.ACME
});
// TODO: associate the certificate with the order
@@ -647,9 +672,9 @@ export const pkiAcmeServiceFactory = ({
logger.error(exp, "Failed to sign certificate");
// TODO: audit log the error
if (exp instanceof BadRequestError) {
errorToReturn = new AcmeBadCSRError({ detail: `Invalid CSR: ${exp.message}` });
errorToReturn = new AcmeBadCSRError({ message: `Invalid CSR: ${exp.message}` });
} else {
errorToReturn = new AcmeServerInternalError({ detail: "Failed to sign certificate with internal error" });
errorToReturn = new AcmeServerInternalError({ message: "Failed to sign certificate with internal error" });
}
}
return {

View File

@@ -252,8 +252,7 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider
error: error.name,
status: error.status,
type: `urn:ietf:params:acme:error:${error.type}`,
detail: error.detail,
message: error.message
detail: error.message
// TODO: add subproblems if they exist
});
} else {

84
docker-compose.bdd.yml Normal file
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