mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-07 21:27:31 +00:00
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:
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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} |
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user