├── .dockerignore ├── .github ├── CODEOWNERS ├── dependabot.yml ├── workflows │ ├── release.yaml │ ├── fossa.yaml │ └── ci.yaml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── pull_request_template.md ├── images └── banner.png ├── CHANGELOG.md ├── dev ├── Dockerfile.node ├── Dockerfile.pebble ├── nginx_wait_for_js ├── pebble │ ├── config.json │ ├── cert.pem │ └── key.pem └── Dockerfile.nginx ├── .fossa.yml ├── .prettierrc.yml ├── unit-tests ├── .mocharc.js ├── tsconfig.json ├── setupGlobals.ts ├── utils.test.ts └── logger.test.ts ├── integration-tests ├── .mocharc.js ├── docker-compose.yml ├── Dockerfile ├── nginx.conf ├── acme-auto.test.ts └── hooks.ts ├── .gitignore ├── Dockerfile ├── .eslintrc.json ├── .devcontainer └── devcontainer.json ├── SECURITY.md ├── babel.config.js ├── src ├── tsconfig.json ├── logger.ts ├── examples.ts ├── index.ts ├── x509.js ├── api.ts ├── client.ts └── utils.ts ├── SUPPORT.md ├── docker-compose.yml ├── rollup.config.js ├── Makefile ├── package.json ├── CONTRIBUTING.md ├── CODE_OF_CONDUCT.md ├── examples └── nginx.conf ├── tsconfig.json ├── LICENSE └── README.md /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Main global owner # 2 | ##################### 3 | * 4 | -------------------------------------------------------------------------------- /images/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nginx/njs-acme/HEAD/images/banner.png -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.0.0 (March 25, 2024) 4 | 5 | Initial release of njs-acme. 6 | -------------------------------------------------------------------------------- /dev/Dockerfile.node: -------------------------------------------------------------------------------- 1 | FROM node:18 2 | WORKDIR /app 3 | COPY package*.json . 4 | RUN npm ci 5 | CMD npm run watch 6 | -------------------------------------------------------------------------------- /dev/Dockerfile.pebble: -------------------------------------------------------------------------------- 1 | FROM letsencrypt/pebble:latest 2 | RUN apk --update add curl 3 | 4 | COPY ./pebble/* /etc/pebble/ 5 | -------------------------------------------------------------------------------- /.fossa.yml: -------------------------------------------------------------------------------- 1 | version: 3 2 | 3 | project: 4 | id: github.com/nginx/njs-acme 5 | name: njs-acme 6 | url: github.com/nginx/njs-acme 7 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | # style settings, see https://prettier.io/docs/en/options.html 2 | semi: false 3 | singleQuote: true 4 | trailingComma: es5 5 | tabWidth: 2 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: github-actions 5 | directory: / 6 | schedule: 7 | interval: weekly 8 | day: monday 9 | time: "00:00" 10 | -------------------------------------------------------------------------------- /unit-tests/.mocharc.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | process.env.BABEL_ENV = 'mocha' 4 | 5 | module.exports = { 6 | checkLeaks: true, 7 | extension: ['ts'], 8 | require: [ 9 | 'babel-register-ts', 10 | 'source-map-support/register', 11 | ], 12 | file: 'unit-tests/setupGlobals.ts', 13 | spec: [ 14 | 'unit-tests/**/*.test.ts', 15 | ], 16 | } 17 | -------------------------------------------------------------------------------- /integration-tests/.mocharc.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | process.env.BABEL_ENV = 'mocha' 4 | 5 | module.exports = { 6 | checkLeaks: true, 7 | extension: ['ts'], 8 | require: [ 9 | 'babel-register-ts', 10 | 'source-map-support/register', 11 | 'integration-tests/hooks.ts', 12 | ], 13 | spec: [ 14 | 'integration-tests/**/*.test.ts', 15 | ], 16 | } 17 | -------------------------------------------------------------------------------- /unit-tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["node"] 5 | }, 6 | "files": [ 7 | "../node_modules/njs-types/ngx_http_js_module.d.ts", 8 | "../node_modules/njs-types/njs_webcrypto.d.ts", 9 | "../node_modules/njs-types/njs_core.d.ts", 10 | "../node_modules/njs-types/njs_modules/crypto.d.ts", 11 | "../node_modules/njs-types/njs_modules/fs.d.ts" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Any private crt and keys # 2 | ############################ 3 | *.crt 4 | *.key 5 | *~ 6 | \#* 7 | 8 | # OS Specific # 9 | ############### 10 | Thumbs.db 11 | .DS_Store 12 | .vscode 13 | 14 | # Logs # 15 | ######## 16 | *.log 17 | 18 | # JavaScript and TypeScript 19 | /dist/ 20 | /lib/ 21 | node_modules/ 22 | *.log 23 | 24 | # keys used in testing 25 | examples/*.public.json 26 | examples/*.private.json 27 | examples/*.key 28 | examples/*.csr 29 | examples/account_private_key.json 30 | examples/.well-known/* 31 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | ARG NGINX_VERSION=1.25.3 3 | 4 | FROM node:20 AS builder 5 | WORKDIR /app 6 | COPY package.json package-lock.json ./ 7 | RUN --mount=type=cache,target=/app/.npm \ 8 | npm set cache /app/.npm && \ 9 | npm ci 10 | COPY . . 11 | RUN npm run build 12 | 13 | FROM nginx:${NGINX_VERSION} 14 | COPY --from=builder /app/dist/acme.js /usr/lib/nginx/njs_modules/acme.js 15 | COPY ./examples/nginx.conf /etc/nginx/nginx.conf 16 | RUN mkdir /etc/nginx/njs-acme 17 | RUN chown nginx: /etc/nginx/njs-acme 18 | -------------------------------------------------------------------------------- /dev/nginx_wait_for_js: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | JS_FILE=/usr/lib/nginx/njs_modules/acme.js 3 | 4 | # wait until the .js file appears 5 | while ! [ -f $JS_FILE ] 6 | do 7 | echo "Waiting for $JS_FILE to appear..." 8 | sleep 1 9 | done 10 | 11 | # start nginx in background 12 | echo "Starting nginx..." 13 | $@ & 14 | 15 | sleep 3 16 | 17 | echo "Watching for changes..." 18 | 19 | # when the .js file is modified, reload nginx 20 | inotifywait -m -e create,modify $JS_FILE | 21 | while read filename 22 | do 23 | $1 -s reload 24 | done 25 | -------------------------------------------------------------------------------- /dev/pebble/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "pebble": { 3 | "listenAddress": "0.0.0.0:443", 4 | "managementListenAddress": "0.0.0.0:15000", 5 | "certificate": "/etc/pebble/cert.pem", 6 | "privateKey": "/etc/pebble/key.pem", 7 | "httpPort": 8000, 8 | "tlsPort": 5001, 9 | "ocspResponderURL": "", 10 | "externalAccountBindingRequired": false, 11 | "domainBlocklist": ["blocked.example"], 12 | "retryAfter": { 13 | "authz": 30, 14 | "order": 50 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:@typescript-eslint/recommended", 5 | "plugin:prettier/recommended" 6 | ], 7 | "ignorePatterns": ["**/node_modules/", "/dist/", "/lib/"], 8 | "parserOptions": { 9 | "sourceType": "module" 10 | }, 11 | "rules": { 12 | "@typescript-eslint/no-unused-vars": [ 13 | "warn", 14 | { 15 | "argsIgnorePattern": "^_", 16 | "varsIgnorePattern": "^_", 17 | "caughtErrorsIgnorePattern": "^_" 18 | } 19 | ] 20 | }, 21 | "root": true 22 | } 23 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Upload acme.js to release page 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | build: 9 | name: Build release artifact 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v4 14 | 15 | - name: Build dist/acme.js 16 | run: make build 17 | 18 | - name: Upload acme.js to release 19 | env: 20 | GH_TOKEN: ${{ github.token }} 21 | run: gh release upload "${{ github.event.release.tag_name }}" ./dist/acme.js 22 | -------------------------------------------------------------------------------- /.github/workflows/fossa.yaml: -------------------------------------------------------------------------------- 1 | name: License Scanning 2 | 3 | on: 4 | - pull_request 5 | - push 6 | 7 | jobs: 8 | fossa: 9 | # This job is only useful when run on upstream 10 | if: github.repository == 'nginxinc/ngx-rust' || github.event_name == 'workflow_dispatch' 11 | runs-on: FOSSA scan 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | 16 | - name: Run FOSSA scan and upload build data 17 | uses: fossa-contrib/fossa-action@v3 18 | with: 19 | fossa-api-key: ${{ secrets.FOSSA_API_KEY }} 20 | -------------------------------------------------------------------------------- /dev/Dockerfile.nginx: -------------------------------------------------------------------------------- 1 | ARG NGINX_VERSION=1.25.3 2 | FROM nginx:${NGINX_VERSION} 3 | ARG NGINX_VERSION 4 | 5 | RUN --mount=type=cache,target=/var/cache/apt < = global 23 | globalAny.ngx = new FakeNGX() 24 | globalAny.crypto = webcrypto 25 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. 2 | { 3 | "name": "njsacme", 4 | "dockerComposeFile": ["../docker-compose.yml"], 5 | "service": "node", 6 | "workspaceFolder": "/app", 7 | "shutdownAction": "stopCompose", 8 | "customizations": { 9 | "vscode": { 10 | "extensions": [ 11 | "dbaeumer.vscode-eslint", 12 | "ms-vsliveshare.vsliveshare", 13 | "ms-azuretools.vscode-docker", 14 | "esbenp.prettier-vscode" 15 | ], 16 | "settings": { 17 | "[typescript]":{ 18 | "editor.defaultFormatter": "esbenp.prettier-vscode" 19 | } 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | 6 | jobs: 7 | test: 8 | name: Test with nginx ${{ matrix.nginx-version }} 9 | strategy: 10 | matrix: 11 | nginx-version: 12 | - 1.25.x 13 | env: 14 | NGINX_VERSION: ${{ matrix.nginx-version }} 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Install dependencies 19 | run: sudo apt-get install -y ca-certificates 20 | - uses: actions/setup-node@v4 21 | - run: npm ci 22 | - run: npm run lint 23 | - run: npm run build 24 | - run: npm run test:unit 25 | - run: make docker-integration-tests 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | ### Is your feature request related to a problem? Please describe 10 | 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when ... 12 | 13 | ### Describe the solution you'd like 14 | 15 | A clear and concise description of what you want to happen. 16 | 17 | ### Describe alternatives you've considered 18 | 19 | A clear and concise description of any alternative solutions or features you've considered. 20 | 21 | ### Additional context 22 | 23 | Add any other context or screenshots about the feature request here. 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | ### Describe the bug 10 | 11 | A clear and concise description of what the bug is. 12 | 13 | ### To reproduce 14 | 15 | Steps to reproduce the behavior: 16 | 17 | 1. Deploy this project using ... 18 | 2. View output/logs/configuration on ... 19 | 3. See error 20 | 21 | ### Expected behavior 22 | 23 | A clear and concise description of what you expected to happen. 24 | 25 | ### Your environment 26 | 27 | - Version/release of this project or specific commit 28 | 29 | - Target deployment platform 30 | 31 | ### Additional context 32 | 33 | Add any other context about the problem here. 34 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Latest Versions 4 | 5 | We advise users to run or update to the most recent release of the njs-acme. Older versions of the njs-acme may not have all enhancements and/or bug fixes applied to them. 6 | 7 | ## Reporting a Vulnerability 8 | 9 | The F5 Security Incident Response Team (F5 SIRT) has an email alias that makes it easy to report potential security vulnerabilities. 10 | 11 | - If you’re an F5 customer with an active support contract, please contact [F5 Technical Support](https://www.f5.com/services/support). 12 | - If you aren’t an F5 customer, please report any potential or current instances of security vulnerabilities with any F5 product to the F5 Security Incident Response Team at F5SIRT@f5.com 13 | 14 | For more information visit [https://www.f5.com/services/support/report-a-vulnerability](https://www.f5.com/services/support/report-a-vulnerability) 15 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### Proposed changes 2 | 3 | Describe the use case and detail of the change. If this PR addresses an issue on GitHub, make sure to include a link to that issue using one of the [supported keywords](https://docs.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue) here in this description (not in the title of the PR). 4 | 5 | ### Checklist 6 | 7 | Before creating a PR, run through this checklist and mark each as complete. 8 | 9 | - [ ] I have read the [`CONTRIBUTING`](https://github.com/nginx/njs-acme/blob/main/CONTRIBUTING.md) document 10 | - [ ] If applicable, I have added tests that prove my fix is effective or that my feature works 11 | - [ ] If applicable, I have checked that any relevant tests pass after adding my changes 12 | - [ ] I have updated any relevant documentation ([`README.md`](https://github.com/nginx/njs-acme/blob/main/README.md) and [`CHANGELOG.md`](https://github.com/nginx/njs-acme/blob/main/CHANGELOG.md)) 13 | -------------------------------------------------------------------------------- /dev/pebble/cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDGzCCAgOgAwIBAgIIbEfayDFsBtwwDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE 3 | AxMVbWluaWNhIHJvb3QgY2EgMjRlMmRiMCAXDTE3MTIwNjE5NDIxMFoYDzIxMDcx 4 | MjA2MTk0MjEwWjAUMRIwEAYDVQQDEwlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB 5 | AQUAA4IBDwAwggEKAoIBAQCbFMW3DXXdErvQf2lCZ0qz0DGEWadDoF0O2neM5mVa 6 | VQ7QGW0xc5Qwvn3Tl62C0JtwLpF0pG2BICIN+DHdVaIUwkf77iBS2doH1I3waE1I 7 | 8GkV9JrYmFY+j0dA1SwBmqUZNXhLNwZGq1a91nFSI59DZNy/JciqxoPX2K++ojU2 8 | FPpuXe2t51NmXMsszpa+TDqF/IeskA9A/ws6UIh4Mzhghx7oay2/qqj2IIPjAmJj 9 | i73kdUvtEry3wmlkBvtVH50+FscS9WmPC5h3lDTk5nbzSAXKuFusotuqy3XTgY5B 10 | PiRAwkZbEY43JNfqenQPHo7mNTt29i+NVVrBsnAa5ovrAgMBAAGjYzBhMA4GA1Ud 11 | DwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwDAYDVR0T 12 | AQH/BAIwADAiBgNVHREEGzAZgglsb2NhbGhvc3SCBnBlYmJsZYcEfwAAATANBgkq 13 | hkiG9w0BAQsFAAOCAQEAYIkXff8H28KS0KyLHtbbSOGU4sujHHVwiVXSATACsNAE 14 | D0Qa8hdtTQ6AUqA6/n8/u1tk0O4rPE/cTpsM3IJFX9S3rZMRsguBP7BSr1Lq/XAB 15 | 7JP/CNHt+Z9aKCKcg11wIX9/B9F7pyKM3TdKgOpqXGV6TMuLjg5PlYWI/07lVGFW 16 | /mSJDRs8bSCFmbRtEqc4lpwlrpz+kTTnX6G7JDLfLWYw/xXVqwFfdengcDTHCc8K 17 | wtgGq/Gu6vcoBxIO3jaca+OIkMfxxXmGrcNdseuUCa3RMZ8Qy03DqGu6Y6XQyK4B 18 | W8zIG6H9SVKkAznM2yfYhW8v2ktcaZ95/OBHY97ZIw== 19 | -----END CERTIFICATE----- 20 | -------------------------------------------------------------------------------- /integration-tests/docker-compose.yml: -------------------------------------------------------------------------------- 1 | name: njs_acme_integration 2 | services: 3 | pebble: 4 | # image: letsencrypt/pebble:latest 5 | build: 6 | context: ../dev 7 | dockerfile: ./Dockerfile.pebble 8 | command: pebble -config /etc/pebble/config.json 9 | hostname: pebble 10 | ports: 11 | - 14000 12 | - 15000 13 | - 443 14 | healthcheck: 15 | test: ["CMD-SHELL", "curl -k https://localhost:443 || exit 1"] 16 | interval: 1s 17 | timeout: 120s 18 | retries: 120 19 | start_period: 5s 20 | test: 21 | build: 22 | context: ../ 23 | dockerfile: ./integration-tests/Dockerfile 24 | 25 | command: "npm run test:integration" 26 | depends_on: 27 | pebble: 28 | condition: service_healthy 29 | hostname: proxy.nginx.com 30 | volumes: 31 | - ../:/app 32 | - /app/node_modules 33 | - certs:/etc/nginx/njs-acme/ 34 | environment: 35 | - NJS_ACME_DIR=/etc/nginx/njs-acme/ 36 | - NJS_ACME_VERIFY_PROVIDER_HTTPS=false 37 | - NJS_ACME_DIRECTORY_URI=https://pebble/dir 38 | - NJS_ACME_ACCOUNT_EMAIL=test@example.com 39 | - USE_NGINX_BIN_PATH=/usr/sbin/nginx 40 | - NGINX_HOSTNAME=proxy.nginx.com 41 | ports: 42 | - 8000 43 | - 4443 44 | volumes: 45 | certs: 46 | -------------------------------------------------------------------------------- /integration-tests/Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | FROM node:20-bullseye 3 | 4 | ENV NJS_ACME_DIR=/etc/nginx/njs-acme/ 5 | 6 | # install nginx and njs 7 | RUN --mount=type=cache,target=/var/cache/apt </dev/null 16 | gpg --dry-run --quiet --no-keyring --import --import-options import-show \ 17 | /usr/share/keyrings/nginx-archive-keyring.gpg 18 | echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] \ 19 | http://nginx.org/packages/mainline/debian `lsb_release -cs` nginx" \ 20 | | tee /etc/apt/sources.list.d/nginx.list 21 | apt update -qq 22 | apt install -qq --yes --no-install-recommends --no-install-suggests \ 23 | nginx nginx-module-njs 24 | apt remove --purge --auto-remove --yes 25 | rm -rf /var/lib/apt/lists/* /etc/apt/sources.list.d/nginx.list 26 | EOF 27 | RUN mkdir -p ${NJS_ACME_DIR} 28 | 29 | WORKDIR /app 30 | 31 | COPY package*.json . 32 | RUN npm ci 33 | CMD npm run test 34 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | // @ts-check 3 | 4 | /** @type {babel.ConfigFunction} */ 5 | // eslint-disable-next-line no-undef 6 | module.exports = (api) => ({ 7 | presets: [ 8 | // Transpile modern JavaScript into code compatible with njs. 9 | // This is used only for building the dist bundle with Rollup. 10 | ...(api.env('njs') ? ['babel-preset-njs'] : []), 11 | // Parse TypeScript syntax and transform it to JavaScript (i.e. it strips 12 | // type annotations, but does not perform type checking). 13 | [ 14 | '@babel/preset-typescript', 15 | { 16 | allowDeclareFields: true, 17 | }, 18 | ], 19 | ], 20 | 21 | plugins: [ 22 | ...(!api.caller((c) => c && c.supportsStaticESM) 23 | ? [ 24 | // Transform ES modules to CommonJS if needed needed for Mocha tests). 25 | // Mocha, babel-node, babel/register etc. don't understand ES module 26 | // syntax, so we have to transform it to CommonJS. 27 | // This is not used with Rollup. 28 | '@babel/plugin-transform-modules-commonjs', 29 | ] 30 | : []), 31 | ...(api.env('mocha') 32 | ? [ 33 | // Transform power-assert. This is used only for Mocha tests. 34 | 'babel-plugin-empower-assert', 35 | 'babel-plugin-espower', 36 | ] 37 | : []), 38 | ], 39 | }) 40 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ES5", 5 | "module": "es2015", 6 | "lib": [ 7 | "ES2015", 8 | "ES2016.Array.Include", 9 | "ES2017.Object", 10 | "ES2017.String", 11 | "ES2015.promise" 12 | ], 13 | "declaration": true, 14 | "outDir": "../lib", 15 | "composite": true, 16 | "tsBuildInfoFile": "../node_modules/.cache/tsc/src.tsbuildinfo", 17 | "noEmit": false, //true, 18 | "emitDeclarationOnly": true, 19 | "allowJs": true, 20 | // "strict": true, 21 | // "noImplicitAny": true, 22 | // "strictNullChecks": true, 23 | // "strictFunctionTypes": true, 24 | // "strictBindCallApply": true, 25 | // "strictPropertyInitialization": true, 26 | // "noImplicitThis": true, 27 | // "alwaysStrict": true, 28 | "isolatedModules": true, 29 | "resolveJsonModule": true, 30 | "rootDir": "../", 31 | "typeRoots": [], 32 | }, 33 | "include": [".", "../package.json"], 34 | "exclude": ["../node_modules"], 35 | "files": [ 36 | "../node_modules/njs-types/ngx_http_js_module.d.ts", 37 | "../node_modules/njs-types/njs_webcrypto.d.ts", 38 | "../node_modules/njs-types/njs_core.d.ts", 39 | "../node_modules/njs-types/njs_modules/crypto.d.ts", 40 | "../node_modules/njs-types/njs_modules/fs.d.ts" 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /SUPPORT.md: -------------------------------------------------------------------------------- 1 | # Support 2 | 3 | ## Ask a Question 4 | 5 | We use GitHub for tracking bugs and feature requests related to this project. 6 | 7 | Don't know how something in this project works? Curious if this project can achieve your desired functionality? Please open an issue on GitHub with the label `question`. 8 | 9 | ## NGINX Specific Questions and/or Issues 10 | 11 | This isn't the right place to get support for NGINX specific questions, but the following resources are available below. Thanks for your understanding! 12 | 13 | ### Community Slack 14 | 15 | We have a community [Slack](https://nginxcommunity.slack.com/)! 16 | 17 | If you are not a member click [here](https://community.nginx.org/joinslack) to sign up (and let us know if the link does not seem to be working!) 18 | 19 | Once you join, check out the `#beginner-questions` and `nginx-users` channels :) 20 | 21 | ### Documentation 22 | 23 | For a comprehensive list of all NGINX directives, check out . 24 | 25 | For a comprehensive list of admin and deployment guides for all NGINX products, check out . 26 | 27 | ### Mailing List 28 | 29 | Want to get in touch with the NGINX development team directly? Try using the relevant mailing list found at ! 30 | 31 | ## Contributing 32 | 33 | Please see the [contributing guide](https://github.com/nginx/njs-acme/blob/main/CONTRIBUTING.md) for guidelines on how to best contribute to this project. 34 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | pebble: 3 | # image: letsencrypt/pebble:latest 4 | build: 5 | context: ./dev 6 | dockerfile: ./Dockerfile.pebble 7 | command: pebble -config /etc/pebble/config.json 8 | hostname: pebble 9 | ports: 10 | - 14000 # HTTPS ACME API 11 | - 15000 # HTTPS Management API 12 | - 443 # Directory 13 | healthcheck: 14 | test: ["CMD-SHELL", "curl -k https://localhost:443 || exit 1"] 15 | interval: 1s 16 | timeout: 120s 17 | retries: 120 18 | start_period: 5s 19 | node: 20 | build: 21 | context: . 22 | dockerfile: dev/Dockerfile.node 23 | volumes: 24 | - .:/app 25 | - /app/node_modules 26 | - node_dist:/app/dist 27 | nginx: 28 | build: 29 | dockerfile: dev/Dockerfile.nginx 30 | command: /nginx_wait_for_js nginx -c /etc/nginx/nginx.conf 31 | depends_on: 32 | - node 33 | hostname: proxy.nginx.com 34 | volumes: 35 | - ./examples/nginx.conf:/etc/nginx/nginx.conf 36 | - ./dev/nginx_wait_for_js:/nginx_wait_for_js 37 | - node_dist:/usr/lib/nginx/njs_modules/ 38 | - certs:/etc/nginx/njs-acme/ 39 | environment: 40 | - NJS_ACME_VERIFY_PROVIDER_HTTPS=false # only in development environment 41 | - NJS_ACME_DIRECTORY_URI=https://pebble/dir # development server 42 | ports: 43 | - 8000:8000 # http 44 | - 8443:8443 # https 45 | networks: 46 | default: 47 | aliases: 48 | - proxy2.nginx.com 49 | volumes: 50 | certs: 51 | node_dist: 52 | -------------------------------------------------------------------------------- /integration-tests/nginx.conf: -------------------------------------------------------------------------------- 1 | daemon off; 2 | 3 | # this is a default location in the official Nginx Docker installation (Linux) 4 | load_module /usr/lib/nginx/modules/ngx_http_js_module.so; 5 | 6 | 7 | error_log stderr debug; 8 | 9 | events {}; 10 | 11 | http { 12 | js_path "__CWD__/dist"; 13 | # js_fetch_trusted_certificate /etc/ssl/certs/ISRG_Root_X1.pem; 14 | js_import acme from acme.js; 15 | 16 | # One `resolver` directive must be defined. 17 | resolver 127.0.0.11 ipv6=off; # docker-compose 18 | resolver_timeout 5s; 19 | 20 | js_shared_dict_zone zone=acme:128k; 21 | 22 | server { 23 | # pebble usees 8000 as `httpPort` in dev/pebble/config.json so it can validate challebges 24 | # and nginx must use the same 25 | listen 0.0.0.0:__PORT__; 26 | listen __PORT_1__ ssl; 27 | server_name __ADDRESS__; 28 | 29 | js_var $njs_acme_server_names __ADDRESS__; 30 | js_var $njs_acme_account_email test@example.com; 31 | js_var $njs_acme_shared_dict_zone_name acme; 32 | 33 | js_set $dynamic_ssl_cert acme.js_cert; 34 | js_set $dynamic_ssl_key acme.js_key; 35 | 36 | ssl_certificate data:$dynamic_ssl_cert; 37 | ssl_certificate_key data:$dynamic_ssl_key; 38 | 39 | location = /health { 40 | return 200 'OK'; 41 | } 42 | 43 | location ~ "^/\.well-known/acme-challenge/[-_A-Za-z0-9]{22,128}$" { 44 | js_content acme.challengeResponse; 45 | } 46 | 47 | location = /acme/auto { 48 | js_content acme.clientAutoModeHTTP; 49 | } 50 | 51 | location = / { 52 | return 200 '{"server_name":"$server_name","ssl_session_id":"$ssl_session_id"}'; 53 | } 54 | 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /integration-tests/acme-auto.test.ts: -------------------------------------------------------------------------------- 1 | import { strict as assert } from 'assert' 2 | import './hooks' 3 | 4 | interface CertificateData { 5 | certificate: { 6 | issuer: { 7 | [key: string]: string 8 | }[] 9 | domains: { 10 | commonName: string 11 | altNames: string[][] 12 | } 13 | notBefore: string 14 | notAfter: string 15 | } 16 | renewedCertificate: boolean 17 | } 18 | 19 | describe('Integration:AutoMode', async function () { 20 | it('issue cert', async function () { 21 | this.timeout('10s') 22 | const resp = await this.client.get('') 23 | assert.equal(resp.statusCode, 200) 24 | const respBody = JSON.parse(resp.body) 25 | assert.equal(respBody.server_name, this.nginxHost) 26 | assert.equal(respBody.ssl_session_id, '') 27 | 28 | const respCert = await this.client.get('acme/auto') 29 | assert.equal(respCert.statusCode, 200) 30 | const certInfo = JSON.parse(respCert.body) as CertificateData 31 | assert( 32 | certInfo.certificate.domains.commonName.includes('Pebble Intermediate CA') 33 | ) 34 | assert.equal(certInfo.certificate.domains.altNames.length, 1) 35 | assert.equal(certInfo.certificate.domains.altNames[0], this.nginxHost) 36 | 37 | const httpsClient = this.client.extend({ 38 | prefixUrl: `https://${this.nginxHost}:${this.nginx.ports[1]}`, 39 | https: { 40 | rejectUnauthorized: false, 41 | }, 42 | }) 43 | 44 | const httpsResp = await httpsClient.get('') 45 | assert.equal(httpsResp.statusCode, 200) 46 | const httpsBody = JSON.parse(httpsResp.body) 47 | assert.equal(httpsBody.server_name, this.nginxHost) 48 | assert.notEqual(httpsBody.ssl_session_id, '') 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /dev/pebble/key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEowIBAAKCAQEAmxTFtw113RK70H9pQmdKs9AxhFmnQ6BdDtp3jOZlWlUO0Blt 3 | MXOUML5905etgtCbcC6RdKRtgSAiDfgx3VWiFMJH++4gUtnaB9SN8GhNSPBpFfSa 4 | 2JhWPo9HQNUsAZqlGTV4SzcGRqtWvdZxUiOfQ2TcvyXIqsaD19ivvqI1NhT6bl3t 5 | redTZlzLLM6Wvkw6hfyHrJAPQP8LOlCIeDM4YIce6Gstv6qo9iCD4wJiY4u95HVL 6 | 7RK8t8JpZAb7VR+dPhbHEvVpjwuYd5Q05OZ280gFyrhbrKLbqst104GOQT4kQMJG 7 | WxGONyTX6np0Dx6O5jU7dvYvjVVawbJwGuaL6wIDAQABAoIBAGW9W/S6lO+DIcoo 8 | PHL+9sg+tq2gb5ZzN3nOI45BfI6lrMEjXTqLG9ZasovFP2TJ3J/dPTnrwZdr8Et/ 9 | 357YViwORVFnKLeSCnMGpFPq6YEHj7mCrq+YSURjlRhYgbVPsi52oMOfhrOIJrEG 10 | ZXPAwPRi0Ftqu1omQEqz8qA7JHOkjB2p0i2Xc/uOSJccCmUDMlksRYz8zFe8wHuD 11 | XvUL2k23n2pBZ6wiez6Xjr0wUQ4ESI02x7PmYgA3aqF2Q6ECDwHhjVeQmAuypMF6 12 | IaTjIJkWdZCW96pPaK1t+5nTNZ+Mg7tpJ/PRE4BkJvqcfHEOOl6wAE8gSk5uVApY 13 | ZRKGmGkCgYEAzF9iRXYo7A/UphL11bR0gqxB6qnQl54iLhqS/E6CVNcmwJ2d9pF8 14 | 5HTfSo1/lOXT3hGV8gizN2S5RmWBrc9HBZ+dNrVo7FYeeBiHu+opbX1X/C1HC0m1 15 | wJNsyoXeqD1OFc1WbDpHz5iv4IOXzYdOdKiYEcTv5JkqE7jomqBLQk8CgYEAwkG/ 16 | rnwr4ThUo/DG5oH+l0LVnHkrJY+BUSI33g3eQ3eM0MSbfJXGT7snh5puJW0oXP7Z 17 | Gw88nK3Vnz2nTPesiwtO2OkUVgrIgWryIvKHaqrYnapZHuM+io30jbZOVaVTMR9c 18 | X/7/d5/evwXuP7p2DIdZKQKKFgROm1XnhNqVgaUCgYBD/ogHbCR5RVsOVciMbRlG 19 | UGEt3YmUp/vfMuAsKUKbT2mJM+dWHVlb+LZBa4pC06QFgfxNJi/aAhzSGvtmBEww 20 | xsXbaceauZwxgJfIIUPfNZCMSdQVIVTi2Smcx6UofBz6i/Jw14MEwlvhamaa7qVf 21 | kqflYYwelga1wRNCPopLaQKBgQCWsZqZKQqBNMm0Q9yIhN+TR+2d7QFjqeePoRPl 22 | 1qxNejhq25ojE607vNv1ff9kWUGuoqSZMUC76r6FQba/JoNbefI4otd7x/GzM9uS 23 | 8MHMJazU4okwROkHYwgLxxkNp6rZuJJYheB4VDTfyyH/ng5lubmY7rdgTQcNyZ5I 24 | majRYQKBgAMKJ3RlII0qvAfNFZr4Y2bNIq+60Z+Qu2W5xokIHCFNly3W1XDDKGFe 25 | CCPHSvQljinke3P9gPt2HVdXxcnku9VkTti+JygxuLkVg7E0/SWwrWfGsaMJs+84 26 | fK+mTZay2d3v24r9WKEKwLykngYPyZw5+BdWU0E+xx5lGUd3U4gG 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import addGitMsg from 'rollup-plugin-add-git-msg' 3 | import babel from '@rollup/plugin-babel' 4 | import commonjs from '@rollup/plugin-commonjs' 5 | import resolve from '@rollup/plugin-node-resolve' 6 | import json from '@rollup/plugin-json' 7 | import pkg from './package.json' 8 | 9 | // List of njs built-in modules. 10 | const njsExternals = ['crypto', 'fs', 'querystring'] 11 | // eslint-disable-next-line no-undef 12 | const isEnvProd = process.env.NODE_ENV === 'production' 13 | 14 | /** 15 | * Plugin to fix syntax of the default export to be compatible with njs. 16 | * (https://github.com/rollup/rollup/pull/4182#issuecomment-1002241017) 17 | * 18 | * @return {import('rollup').OutputPlugin} 19 | */ 20 | const fixExportDefault = () => ({ 21 | name: 'fix-export-default', 22 | renderChunk: (code) => ({ 23 | code: code.replace(/\bexport { (\S+) as default };/, 'export default $1;'), 24 | map: null, 25 | }), 26 | }) 27 | 28 | /** 29 | * @type {import('rollup').RollupOptions} 30 | */ 31 | const options = { 32 | input: 'src/index.ts', 33 | external: njsExternals, 34 | plugins: [ 35 | // Transpile TypeScript sources to JS. 36 | babel({ 37 | babelHelpers: 'bundled', 38 | envName: 'njs', 39 | extensions: ['.ts', '.mjs', '.js'], 40 | }), 41 | // Resolve node modules. 42 | resolve({ 43 | extensions: ['.mjs', '.js', '.json', '.ts'], 44 | }), 45 | json(), 46 | // Convert CommonJS modules to ES6 modules. 47 | commonjs(), 48 | // Fix syntax of the default export. 49 | fixExportDefault(), 50 | // Plugins to use in production mode only. 51 | ...(isEnvProd 52 | ? [ 53 | // Add git tag, commit SHA, build date and copyright at top of the file. 54 | addGitMsg(), 55 | ] 56 | : []), 57 | ], 58 | output: { 59 | file: pkg.main, 60 | format: 'es', 61 | }, 62 | } 63 | export default options 64 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := help 2 | 3 | GREP ?= $(shell command -v ggrep 2> /dev/null || command -v grep 2> /dev/null) 4 | AWK ?= $(shell command -v gawk 2> /dev/null || command -v awk 2> /dev/null) 5 | DOCKER ?= docker 6 | PROJECT_NAME ?= njs-acme 7 | DOCKER_IMAGE_NAME ?= nginx/nginx-$(PROJECT_NAME) 8 | GITHUB_REPOSITORY ?= nginx/$(PROJECT_NAME) 9 | SRC_REPO := https://github.com/$(GITHUB_REPOSITORY) 10 | 11 | Q = $(if $(filter 1,$V),,@) 12 | M = $(shell printf "\033[34;1m▶\033[0m") 13 | 14 | .PHONY: help 15 | help: 16 | @$(GREP) --no-filename -E '^[ a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ 17 | $(AWK) 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-28s\033[0m %s\n", $$1, $$2}' | sort 18 | 19 | 20 | .PHONY: 21 | build: ## Run npm run build 22 | $Q echo "$(M) building in release mode for the current platform" 23 | $Q npm ci && npm run build 24 | 25 | 26 | .PHONY: docker-build 27 | docker-build: ## Build docker image 28 | $(DOCKER) buildx build $(DOCKER_BUILD_FLAGS) -t $(DOCKER_IMAGE_NAME) . 29 | 30 | 31 | .PHONY: docker-copy 32 | docker-copy: CONTAINER_NAME=njs_acme_dist_source 33 | docker-copy: docker-build ## Copy the acme.js file out of the container and save in dist/ 34 | mkdir -p dist 35 | $(DOCKER) create --name $(CONTAINER_NAME) $(DOCKER_IMAGE_NAME) 36 | $(DOCKER) cp $(CONTAINER_NAME):/usr/lib/nginx/njs_modules/acme.js dist/acme.js 37 | $(DOCKER) rm -v $(CONTAINER_NAME) 38 | 39 | 40 | .PHONY: docker-nginx 41 | docker-nginx: docker-build ## Start nginx container 42 | $(DOCKER) run --rm -it -p 8000:8000 -p \ 43 | $(DOCKER_IMAGE_NAME) 44 | 45 | 46 | .PHONY: docker-njs 47 | docker-njs: docker-build ## Start nginx container and run `njs` 48 | $(DOCKER) run --rm -it \ 49 | $(DOCKER_IMAGE_NAME) njs 50 | 51 | 52 | .PHONY: docker-devup 53 | docker-devup: docker-build ## Start all docker compose services for development/testing 54 | $(DOCKER) compose up -d 55 | 56 | 57 | .PHONY: docker-reload-nginx 58 | docker-reload-nginx: ## Reload nginx started from `docker compose` 59 | $(DOCKER) compose up -d --force-recreate nginx && $(DOCKER) compose logs -f nginx 60 | 61 | 62 | .PHONY: docker-integration-tests 63 | docker-integration-tests: docker-copy ## Run integration tests in docker 64 | $(DOCKER) compose -f ./integration-tests/docker-compose.yml build 65 | $(DOCKER) compose -f ./integration-tests/docker-compose.yml up -d pebble 66 | $(DOCKER) compose -f ./integration-tests/docker-compose.yml up --no-log-prefix test 67 | -------------------------------------------------------------------------------- /unit-tests/utils.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import { describe, it } from 'mocha' 3 | import { acmeServerNames, isValidHostname } from '../src/utils' 4 | 5 | describe('utils', () => { 6 | describe('isValidHostname', () => { 7 | it('returns true for valid names', () => { 8 | assert(isValidHostname('nginx.com')) 9 | assert(isValidHostname('5guys.nginx.com')) 10 | assert(isValidHostname('5guys.nginx.com.')) 11 | assert(isValidHostname('5-guys.')) 12 | assert(isValidHostname('5')) 13 | assert(isValidHostname('a-z.123')) 14 | assert(isValidHostname('a.'.repeat(100))) 15 | assert(isValidHostname('a'.repeat(61) + '.com')) 16 | }) 17 | it('returns false for invalid names', () => { 18 | assert(!isValidHostname('.com')) 19 | assert(!isValidHostname('.foobarbaz')) 20 | assert(!isValidHostname('*.nginx.com')) 21 | assert(!isValidHostname('-5guys.')) 22 | assert(!isValidHostname('5guys-')) 23 | assert(!isValidHostname('domäin.')) 24 | assert(!isValidHostname(' ')) 25 | assert(!isValidHostname('')) 26 | assert(!isValidHostname('a'.repeat(65) + '.com')) 27 | assert(!isValidHostname('*')) 28 | assert( 29 | !isValidHostname( 30 | // too long - 256 chars 31 | '1234567890abcdef'.repeat(16) 32 | ) 33 | ) 34 | }) 35 | }) 36 | describe('acmeServerNames', () => { 37 | it('returns an array given valid input', () => { 38 | const r = { 39 | variables: { 40 | njs_acme_server_names: null, 41 | }, 42 | } as unknown as NginxHTTPRequest 43 | const testCases = { 44 | 'nginx.com': 1, 45 | 'foo.bar.baz': 1, 46 | 'foo.bar.baz foo.baz': 2, 47 | 'foo. bar. baz.': 3, 48 | 'foo.,bar.': 2, 49 | 'foo.\tbar.': 2, 50 | 'foo. bar.': 2, 51 | } 52 | 53 | for (const [names, expected] of Object.entries(testCases)) { 54 | r.variables.njs_acme_server_names = names 55 | const result = acmeServerNames(r) 56 | assert(result.length === expected) 57 | } 58 | }) 59 | it('throws given invalid input', () => { 60 | const r = { 61 | variables: { 62 | njs_acme_server_names: null, 63 | }, 64 | } as unknown as NginxHTTPRequest 65 | for (const name of [ 66 | 'nginx-.com', 67 | '*.bar.baz', 68 | 'foo.bar.baz *.baz', 69 | '-foo. bar. baz.', 70 | ]) { 71 | r.variables.njs_acme_server_names = name 72 | assert.throws(() => acmeServerNames(r)) 73 | } 74 | }) 75 | }) 76 | }) 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "njs-acme", 3 | "version": "1.0.0", 4 | "description": "## How do I use this template?", 5 | "main": "dist/acme.js", 6 | "files": [ 7 | "dist", 8 | "src" 9 | ], 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/nginx/njs-acme.git" 13 | }, 14 | "keywords": [], 15 | "author": "", 16 | "license": "APACHE-2.0", 17 | "bugs": { 18 | "url": "https://github.com/nginx/njs-acme/issues" 19 | }, 20 | "homepage": "https://github.com/nginx/njs-acme#readme", 21 | "engines": { 22 | "node": ">= 14.15" 23 | }, 24 | "scripts": { 25 | "build": "rollup -c --environment NODE_ENV:dev", 26 | "clean": "rimraf dist/* lib/* node_modules/.cache/*", 27 | "lint": "run-p lint:*", 28 | "lint:eslint": "npx eslint .", 29 | "start": "run-p watch start-nginx", 30 | "start-nginx": "start-nginx --version 1.25.x --port 8090 --watch dist/ integration-tests/nginx.conf", 31 | "test": "run-p test:*", 32 | "test:unit": "mocha --config ./unit-tests/.mocharc.js", 33 | "test:integration": "rollup -c && mocha --config ./integration-tests/.mocharc.js", 34 | "watch": "rollup -c --watch --no-watch.clearScreen", 35 | "prettier": "prettier --check --write src/" 36 | }, 37 | "dependencies": { 38 | "asn1js": "^3.0.5", 39 | "pkijs": "^3.0.14" 40 | }, 41 | "devDependencies": { 42 | "@babel/core": "^7.21.8", 43 | "@babel/plugin-transform-modules-commonjs": "^7.21.5", 44 | "@babel/preset-typescript": "^7.21.5", 45 | "@babel/register": "^7.21.0", 46 | "@rollup/plugin-babel": "^5.3.1", 47 | "@rollup/plugin-commonjs": "^18.1.0", 48 | "@rollup/plugin-json": "^6.0.0", 49 | "@rollup/plugin-node-resolve": "^11.2.1", 50 | "@types/babel__core": "^7.20.0", 51 | "@types/mocha": "^8.2.3", 52 | "@types/rollup-plugin-add-git-msg": "^1.1.1", 53 | "@typescript-eslint/eslint-plugin": "^4.33.0", 54 | "@typescript-eslint/parser": "^4.33.0", 55 | "babel-plugin-empower-assert": "^2.0.0", 56 | "babel-plugin-espower": "^3.0.1", 57 | "babel-preset-njs": "^0.2.1", 58 | "babel-register-ts": "^7.0.0", 59 | "eslint": "^7.32.0", 60 | "eslint-config-prettier": "^8.8.0", 61 | "eslint-plugin-prettier": "^4.2.1", 62 | "got": "^11.8.6", 63 | "mocha": "^10.8.2", 64 | "mocha-suite-hooks": "^0.1.0", 65 | "nginx-testing": "^0.4.0", 66 | "njs-types": "^0.8.2", 67 | "npm-run-all": "^4.1.5", 68 | "power-assert": "^1.6.1", 69 | "prettier": "^2.8.8", 70 | "rimraf": "^3.0.2", 71 | "rollup": "^2.79.1", 72 | "rollup-plugin-add-git-msg": "^1.1.0", 73 | "typescript": "~4.2.4" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /integration-tests/hooks.ts: -------------------------------------------------------------------------------- 1 | // import * as FS from 'fs' 2 | import got, { Got } from 'got' 3 | import { Context, RootHookObject } from 'mocha' 4 | import { beforeEachSuite } from 'mocha-suite-hooks' 5 | import { startNginx, NginxServer, NginxOptions } from 'nginx-testing' 6 | 7 | const nginxConfig = `${__dirname}/nginx.conf` 8 | // use specified "local" already insatlled nginx (e.g. /usr/sbin/nginx) 9 | // instead of pulling nginx binary from nginx-testing lib 10 | const useNginxBinPath = process.env.USE_NGINX_BIN_PATH 11 | // let nginx-testing to know which nginx to download (not official builds) 12 | const nginxVersion = process.env.NGINX_VERSION || '1.25.x' 13 | // 14 | export const host = process.env.NGINX_HOSTNAME || '127.0.0.1' 15 | 16 | declare module 'mocha' { 17 | export interface Context { 18 | client: Got 19 | nginx: NginxServer 20 | nginxHost: string 21 | } 22 | } 23 | 24 | export const mochaHooks: RootHookObject = { 25 | async beforeAll(this: Context) { 26 | this.timeout(30_000) 27 | this.nginxHost = host 28 | 29 | const opts = { 30 | bindAddress: this.nginxHost, 31 | configPath: nginxConfig, 32 | ports: [8000, 4443], 33 | workDir: __dirname, 34 | } as NginxOptions 35 | 36 | if (useNginxBinPath) { 37 | opts.binPath = useNginxBinPath 38 | } else { 39 | opts.version = nginxVersion 40 | } 41 | console.info('running tests in folder', process.cwd()) 42 | console.info('using opts', opts) 43 | this.nginx = await startNginx(opts) 44 | console.info('nginx config\n', this.nginx.config) 45 | 46 | const errors = (await this.nginx.readErrorLog()) 47 | .split('\n') 48 | .filter((line) => line.includes('[error]')) 49 | if (errors) { 50 | console.error(errors.join('\n')) 51 | } 52 | 53 | this.client = got.extend({ 54 | prefixUrl: `http://${this.nginxHost}:${this.nginx.port}`, 55 | throwHttpErrors: false, 56 | }) 57 | 58 | beforeEachSuite(async function () { 59 | await this.nginx.readErrorLog() 60 | await this.nginx.readAccessLog() 61 | }) 62 | }, 63 | 64 | async afterAll(this: Context) { 65 | if (this.nginx) { 66 | await this.nginx.stop() 67 | } 68 | }, 69 | 70 | async afterEach(this: Context) { 71 | const { currentTest, nginx } = this 72 | 73 | if (currentTest?.state === 'failed' && currentTest.err) { 74 | const errorLog = await nginx.readErrorLog() 75 | const accessLog = await nginx.readAccessLog() 76 | 77 | const logs = [ 78 | errorLog && '----- Error Log -----\n' + errorLog, 79 | accessLog && '----- Access Log -----\n' + accessLog, 80 | ].filter(Boolean) 81 | 82 | if (logs.length > 0) { 83 | currentTest.err.stack += 84 | '\n\n' + logs.join('\n\n').replace(/^/gm, ' ') 85 | } 86 | } 87 | }, 88 | } 89 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | The following is a set of guidelines for contributing to this project. We really appreciate that you are considering contributing! 4 | 5 | #### Table Of Contents 6 | 7 | [Getting Started](#getting-started) 8 | 9 | [Contributing](#contributing) 10 | 11 | [Code Guidelines](#code-guidelines) 12 | 13 | [Code of Conduct](https://github.com/nginx/njs-acme/blob/main/CODE_OF_CONDUCT.md) 14 | 15 | ## Getting Started 16 | 17 | Follow our [Getting Started Guide](https://github.com/nginx/njs-acme/blob/main/README.md#Getting-Started) to get this project up and running. 18 | 19 | 20 | 21 | ## Contributing 22 | 23 | ### Report a Bug 24 | 25 | To report a bug, open an issue on GitHub with the label `bug` using the available bug report issue template. Please ensure the bug has not already been reported. **If the bug is a potential security vulnerability, please report it using our [security policy](https://github.com/nginx/njs-acme/blob/main/SECURITY.md).** 26 | 27 | ### Suggest a Feature or Enhancement 28 | 29 | To suggest a feature or enhancement, please create an issue on GitHub with the label `enhancement` using the available [feature request template](https://github.com/nginx/njs-acme/blob/main/.github/ISSUE_TEMPLATE/feature_request.md). Please ensure the feature or enhancement has not already been suggested. 30 | 31 | ### Open a Pull Request 32 | 33 | - Fork the repo, create a branch, implement your changes, add any relevant tests, submit a PR when your changes are **tested** and ready for review. 34 | - Fill in [our pull request template](https://github.com/nginx/njs-acme/blob/main/.github/pull_request_template.md). 35 | 36 | Note: if you'd like to implement a new feature, please consider creating a [feature request issue](https://github.com/nginx/njs-acme/blob/main/.github/ISSUE_TEMPLATE/feature_request.md) first to start a discussion about the feature. 37 | 38 | ## Code Guidelines 39 | 40 | 41 | 42 | ### Git Guidelines 43 | 44 | - Keep a clean, concise and meaningful git commit history on your branch (within reason), rebasing locally and squashing before submitting a PR. 45 | - If possible and/or relevant, use the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) format when writing a commit message, so that changelogs can be automatically generated 46 | - Follow the guidelines of writing a good commit message as described here and summarised in the next few points: 47 | - In the subject line, use the present tense ("Add feature" not "Added feature"). 48 | - In the subject line, use the imperative mood ("Move cursor to..." not "Moves cursor to..."). 49 | - Limit the subject line to 72 characters or less. 50 | - Reference issues and pull requests liberally after the subject line. 51 | - Add more detailed description in the body of the git message (`git commit -a` to give you more space and time in your text editor to write a good message instead of `git commit -am`). 52 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | export enum LogLevel { 2 | /** 3 | * Debug is a synthetic log level to control log verbosity. 4 | * 5 | * ngx.INFO is the lowest level that njs supports, but often there are logs 6 | * only useful during the development process. 7 | */ 8 | Debug = 1, 9 | Info, 10 | Warn, 11 | Error, 12 | } 13 | 14 | /** what Logger needs from the global ngx */ 15 | type NgxLog = Pick 16 | 17 | /** 18 | * Logger is a leveled logger on top of ngx.log that adds a consistent 19 | * prefix to log messages. 20 | * 21 | * @example 22 | * const log = new Logger('my module') 23 | * log.info('foo') 24 | * // equivalent to `ngx.log(ngx.INFO, 'njs-acme: [my module] foo')` 25 | * 26 | * log.debug('bar') // does nothing by default 27 | * log.minLevel = LogLevel.Debug // enabling more verbosity 28 | * 29 | * log.debug('bar') 30 | * // now equivalent to `ngx.log(ngx.INFO, 'njs-acme: [my module] bar')` 31 | * 32 | * // multiple args are stringified and joined on space: 33 | * log.info('baz:', true, {key: "value"}) 34 | * // equivalent to `ngx.log(ngx.INFO, 'njs-acme: [my module] baz: true {"key":"value"}')` 35 | */ 36 | export class Logger { 37 | private prefix: string 38 | private readonly logLevelMap: Record 39 | private readonly ngx: NgxLog 40 | 41 | /** 42 | * @param module preprended to every log message, if non-empty 43 | * @param minLevel lowest level to log, anything below will be ignored 44 | * @param ngx log sink, intended for testing purposes 45 | */ 46 | constructor( 47 | module = '', 48 | public minLevel: LogLevel = LogLevel.Info, 49 | base?: NgxLog 50 | ) { 51 | // the global `ngx` object is late bound, and undefined if we use it as a 52 | // default parameter 53 | this.ngx = base ?? ngx 54 | this.prefix = module ? `njs-acme: [${module}]` : 'njs-acme:' 55 | this.logLevelMap = { 56 | [LogLevel.Debug]: this.ngx.INFO, 57 | [LogLevel.Info]: this.ngx.INFO, 58 | [LogLevel.Warn]: this.ngx.WARN, 59 | [LogLevel.Error]: this.ngx.ERR, 60 | } 61 | } 62 | 63 | private log(level: LogLevel, args: unknown[]) { 64 | if (args.length === 0) { 65 | return 66 | } 67 | if (level < this.minLevel) { 68 | return 69 | } 70 | 71 | const message = [this.prefix, ...args] 72 | .map((a) => (typeof a === 'string' ? a : JSON.stringify(a))) 73 | .join(' ') 74 | 75 | this.ngx.log(this.logLevelMap[level], message) 76 | } 77 | 78 | /** 79 | * debug is a synthetic log level to control verbosity, use this for logs that 80 | * are useful only during the development process. 81 | * 82 | * Will appear in logs as ngx.INFO. 83 | * */ 84 | debug(...args: unknown[]): void { 85 | this.log(LogLevel.Debug, args) 86 | } 87 | info(...args: unknown[]): void { 88 | this.log(LogLevel.Info, args) 89 | } 90 | warn(...args: unknown[]): void { 91 | this.log(LogLevel.Warn, args) 92 | } 93 | error(...args: unknown[]): void { 94 | this.log(LogLevel.Error, args) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /unit-tests/logger.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import { describe, it } from 'mocha' 3 | import { LogLevel, Logger } from '../src/logger' 4 | 5 | describe('Logger', () => { 6 | it('adds a prefix', () => { 7 | const ngx = new FakeNGX() 8 | const log = new Logger('my-module', LogLevel.Info, ngx) 9 | 10 | log.info('message') 11 | 12 | assert.deepEqual(ngx.logs, [ 13 | { level: ngx.INFO, message: 'njs-acme: [my-module] message' }, 14 | ]) 15 | }) 16 | 17 | it('omits empty modules from the prefix', () => { 18 | const ngx = new FakeNGX() 19 | const log = new Logger('', LogLevel.Info, ngx) 20 | 21 | log.info('message') 22 | 23 | assert.deepEqual(ngx.logs, [ 24 | { level: ngx.INFO, message: 'njs-acme: message' }, 25 | ]) 26 | }) 27 | 28 | it("maps our four log levels to ngx's three log levels", () => { 29 | const ngx = new FakeNGX() 30 | const log = new Logger('t', LogLevel.Debug, ngx) 31 | 32 | log.debug('d') 33 | log.info('i') 34 | log.warn('w') 35 | log.error('e') 36 | 37 | assert.deepEqual(ngx.logs, [ 38 | { level: ngx.INFO, message: 'njs-acme: [t] d' }, 39 | { level: ngx.INFO, message: 'njs-acme: [t] i' }, 40 | { level: ngx.WARN, message: 'njs-acme: [t] w' }, 41 | { level: ngx.ERR, message: 'njs-acme: [t] e' }, 42 | ]) 43 | }) 44 | 45 | it('omits logs below the minLevel', () => { 46 | const ngx = new FakeNGX() 47 | const log = new Logger('t', LogLevel.Info, ngx) 48 | 49 | log.debug('d') 50 | log.info('i') 51 | log.warn('w') 52 | log.error('e') 53 | 54 | assert.deepEqual(ngx.logs, [ 55 | { level: ngx.INFO, message: 'njs-acme: [t] i' }, 56 | { level: ngx.WARN, message: 'njs-acme: [t] w' }, 57 | { level: ngx.ERR, message: 'njs-acme: [t] e' }, 58 | ]) 59 | }) 60 | 61 | const testCases: Record = { 62 | 'multiple args': { 63 | args: ['msg:', 4, true, 'another'], 64 | expected: 'msg: 4 true another', 65 | }, 66 | objects: { 67 | args: ['did a thing:', { a: 1, b: 2 }], 68 | expected: 'did a thing: {"a":1,"b":2}', 69 | }, 70 | 'empty-ish args': { 71 | args: [null, '', undefined], 72 | expected: 'null ', 73 | }, 74 | arrays: { 75 | args: ['a:', [1, 2, 3]], 76 | expected: 'a: [1,2,3]', 77 | }, 78 | } 79 | 80 | for (const [name, testCase] of Object.entries(testCases)) { 81 | it(`stringifies ${name}`, () => { 82 | const ngx = new FakeNGX() 83 | const log = new Logger('t', LogLevel.Info, ngx) 84 | 85 | log.info(...testCase.args) 86 | 87 | assert.deepEqual(ngx.logs, [ 88 | { level: ngx.INFO, message: `njs-acme: [t] ${testCase.expected}` }, 89 | ]) 90 | }) 91 | } 92 | }) 93 | 94 | /** 95 | * Fake implementation of the logging functions of NGXObject 96 | */ 97 | class FakeNGX { 98 | readonly logs: { level: number; message: NjsStringOrBuffer }[] 99 | readonly INFO: number 100 | readonly WARN: number 101 | readonly ERR: number 102 | constructor() { 103 | this.logs = [] 104 | this.INFO = 1 105 | this.WARN = 2 106 | this.ERR = 3 107 | } 108 | log(level: number, message: NjsStringOrBuffer) { 109 | this.logs.push({ level, message }) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the moderation team at nginx-oss-community@f5.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 1.4, 71 | available at 72 | 73 | For answers to common questions about this code of conduct, see 74 | 75 | -------------------------------------------------------------------------------- /examples/nginx.conf: -------------------------------------------------------------------------------- 1 | ## 2 | # Example configuration showing a single server block to handle HTTP and HTTP 3 | # communications, as well as automatcally issuing / renewing the TLS 4 | # certificate. See the other files in this directory for other examples. 5 | ## 6 | 7 | daemon off; 8 | user nginx; 9 | 10 | load_module modules/ngx_http_js_module.so; 11 | 12 | error_log /dev/stdout debug; 13 | 14 | events { 15 | } 16 | 17 | http { 18 | js_path "/usr/lib/nginx/njs_modules/"; 19 | js_fetch_trusted_certificate /etc/ssl/certs/ISRG_Root_X1.pem; 20 | 21 | # Read the .js file into the `acme` namespace. 22 | js_import acme from acme.js; 23 | 24 | # IMPORTANT: One `resolver` directive *must* be defined. 25 | resolver 127.0.0.11 ipv6=off; # docker-compose 26 | # resolver 1.1.1.1 1.0.0.1 [2606:4700:4700::1111] [2606:4700:4700::1001] valid=300s; # Cloudflare 27 | # resolver 8.8.8.8 8.8.4.4; # Google 28 | # resolver 172.16.0.23; # AWS EC2 Classic 29 | # resolver 169.254.169.253; # AWS VPC 30 | resolver_timeout 5s; 31 | 32 | ## 33 | # `njs-acme` can use a shared dict to cache cert/key pairs to avoid 34 | # filesystem calls on TLS handshake. If you want to use a shared zone name 35 | # that is not `acme`, then ensure the variable $njs_acme_shared_dict_zone_name 36 | # also contains the desired name. The zone size should be enough to store all 37 | # certs and keys. 1MB should be enough to store 100 certs/keys. 38 | js_shared_dict_zone zone=acme:1m; 39 | 40 | server { 41 | listen 8000; 42 | listen 8443 ssl; 43 | server_name _default; 44 | 45 | ## Mandatory Variables 46 | # These, and other variables, may also be defined in 47 | # environment variables, just without the leading dollar sign and with the 48 | # variable name in upper case, e.g. `NJS_ACME_SERVER_NAMES`. 49 | js_var $njs_acme_server_names 'proxy.nginx.com proxy2.nginx.com'; 50 | js_var $njs_acme_account_email 'test@example.com'; 51 | 52 | ## Optional Variables and their defaults. 53 | # js_var $njs_acme_dir /etc/nginx/njs-acme; 54 | # js_var $njs_acme_challenge_dir /etc/nginx/njs-acme/challenge; 55 | # js_var $njs_acme_account_private_jwk /etc/nginx/njs-acme/account_private_key.json; 56 | # js_var $njs_acme_directory_uri https://acme-staging-v02.api.letsencrypt.org/directory; 57 | # js_var $njs_acme_verify_provider_https true; 58 | # js_var $njs_acme_shared_dict_zone_name acme; 59 | 60 | 61 | ## Let's Encrypt Production URL (uncomment after you are done testing with their staging environment) 62 | # js_var $njs_acme_directory_uri https://acme-v02.api.letsencrypt.org/directory 63 | 64 | # Stores the key/cert content in these variables. 65 | js_set $dynamic_ssl_cert acme.js_cert; 66 | js_set $dynamic_ssl_key acme.js_key; 67 | 68 | # Uses the key/cert stored in variables for HTTPS 69 | ssl_certificate data:$dynamic_ssl_cert; 70 | ssl_certificate_key data:$dynamic_ssl_key; 71 | 72 | # `js_periodic` must be in a location {} block, so use a named location to 73 | # avoid affecting URI space. 74 | # From https://nginx.org/en/docs/http/ngx_http_core_module.html#location 75 | # The “@” prefix defines a named location. Such a location is not used for a 76 | # regular request processing, but instead used for request redirection. 77 | location @acmePeriodicAuto { 78 | # Check certificate validity each minute 79 | js_periodic acme.clientAutoMode interval=1m; 80 | } 81 | 82 | # Respond challenges from the ACME server (e.g. Let's Encrypt) 83 | location ~ "^/\.well-known/acme-challenge/[-_A-Za-z0-9]{22,128}$" { 84 | js_content acme.challengeResponse; 85 | } 86 | 87 | # Your location(s) go here 88 | location = / { 89 | return 200 "hello server_name:$server_name\nssl_session_id:$ssl_session_id\n"; 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/examples.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ADDITIONAL EXAMPLES - Not required for the baseline implementation in 3 | * `index.ts` but may be interesting to people who want to create their own 4 | * implementations that may facilitate account creation or CSR generation. 5 | */ 6 | 7 | import { HttpClient } from './api' 8 | import { LogLevel, Logger } from './logger' 9 | import { 10 | acmeAccountPrivateJWKPath, 11 | acmeDirectoryURI, 12 | acmeVerifyProviderHTTPS, 13 | createCsr, 14 | generateKey, 15 | readOrCreateAccountKey, 16 | toPEM, 17 | } from './utils' 18 | import { AcmeClient } from './client' 19 | 20 | const log = new Logger() 21 | 22 | /** 23 | * Demonstrates how to use generate RSA Keys and use HttpClient 24 | * @param r 25 | * @returns 26 | */ 27 | async function acmeNewAccount(r: NginxHTTPRequest): Promise { 28 | // Generate a new RSA key pair for ACME account 29 | const keys = (await generateKey()) as Required 30 | 31 | // Create a new ACME account 32 | const client = new HttpClient(acmeDirectoryURI(r), keys.privateKey) 33 | 34 | client.minLevel = LogLevel.Debug 35 | client.setVerify(acmeVerifyProviderHTTPS(r)) 36 | 37 | // Get Terms Of Service link from the ACME provider 38 | const tos = await client.getMetaField('termsOfService') 39 | log.info(`termsOfService: ${tos}`) 40 | // obtain a resource URL 41 | const resourceUrl: string = await client.getResourceUrl('newAccount') 42 | const payload = { 43 | termsOfServiceAgreed: true, 44 | contact: ['mailto:test@example.com'], 45 | } 46 | // Send a signed request 47 | const sresp = await client.signedRequest(resourceUrl, payload) 48 | 49 | const respO = { 50 | headers: sresp.headers, 51 | data: await sresp.json(), 52 | status: sresp.status, 53 | } 54 | return r.return(200, JSON.stringify(respO)) 55 | } 56 | 57 | /** 58 | * Using AcmeClient to create a new account. It creates an account key if it doesn't exist 59 | * @param {NginxHTTPRequest} r Incoming request 60 | * @returns void 61 | */ 62 | async function clientNewAccount(r: NginxHTTPRequest): Promise { 63 | const accountKey = await readOrCreateAccountKey(acmeAccountPrivateJWKPath(r)) 64 | // Create a new ACME account 65 | const client = new AcmeClient({ 66 | directoryUrl: acmeDirectoryURI(r), 67 | accountKey: accountKey, 68 | }) 69 | // display more logs 70 | client.api.minLevel = LogLevel.Debug 71 | // conditionally validate ACME provider cert 72 | client.api.setVerify(acmeVerifyProviderHTTPS(r)) 73 | 74 | try { 75 | const account = await client.createAccount({ 76 | termsOfServiceAgreed: true, 77 | contact: ['mailto:test@example.com'], 78 | }) 79 | return r.return(200, JSON.stringify(account)) 80 | } catch (e) { 81 | const errMsg = `Error creating ACME account. Error=${e}` 82 | log.error(errMsg) 83 | return r.return(500, errMsg) 84 | } 85 | } 86 | 87 | /** 88 | * Create a new certificate Signing Request - Example implementation 89 | * @param r 90 | * @returns 91 | */ 92 | async function createCsrHandler(r: NginxHTTPRequest): Promise { 93 | const { pkcs10Ber, keys } = await createCsr({ 94 | // EXAMPLE VALUES BELOW 95 | altNames: ['proxy1.f5.com', 'proxy2.f5.com'], 96 | commonName: 'proxy.f5.com', 97 | state: 'WA', 98 | country: 'US', 99 | organizationUnit: 'NGINX', 100 | }) 101 | const privkey = (await crypto.subtle.exportKey( 102 | 'pkcs8', 103 | keys.privateKey 104 | )) as ArrayBuffer 105 | const pubkey = (await crypto.subtle.exportKey( 106 | 'spki', 107 | keys.publicKey 108 | )) as ArrayBuffer 109 | const privkeyPem = toPEM(privkey, 'PRIVATE KEY') 110 | const pubkeyPem = toPEM(pubkey, 'PUBLIC KEY') 111 | const csrPem = toPEM(pkcs10Ber, 'CERTIFICATE REQUEST') 112 | const result = `${privkeyPem}\n${pubkeyPem}\n${csrPem}` 113 | return r.return(200, result) 114 | } 115 | 116 | export default { 117 | acmeNewAccount, 118 | clientNewAccount, 119 | createCsrHandler, 120 | } 121 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | /* Basic Options */ 5 | "incremental": true, /* Enable incremental compilation */ 6 | "target": "ES2020", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 7 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 8 | // "lib": [] /* Specify library files to be included in the compilation. */ 9 | "allowJs": true, /* Allow javascript files to be compiled. */ 10 | // "checkJs": true, /* Report errors in .js files. */ 11 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 12 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 13 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 14 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 15 | // "outFile": "./", /* Concatenate and emit output to single file. */ 16 | // "outDir": "./", /* Redirect output structure to the directory. */ 17 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 18 | // "composite": true, /* Enable project compilation */ 19 | "tsBuildInfoFile": "./node_modules/.cache/tsc/root.tsbuildinfo", /* Specify file to store incremental compilation information */ 20 | // "removeComments": true, /* Do not emit comments to output. */ 21 | "noEmit": true, /* Do not emit outputs. */ 22 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 23 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 24 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 25 | /* Strict Type-Checking Options */ 26 | "strict": true, /* Enable all strict type-checking options. */ 27 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 28 | "strictNullChecks": true, /* Enable strict null checks. */ 29 | "strictFunctionTypes": true, /* Enable strict checking of function types. */ 30 | "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 31 | "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 32 | "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 33 | "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 34 | /* Additional Checks */ 35 | "noUnusedLocals": true, /* Report errors on unused locals. */ 36 | "noUnusedParameters": true, /* Report errors on unused parameters. */ 37 | "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 38 | "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 39 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 40 | // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ 41 | /* Module Resolution Options */ 42 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 43 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 44 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 45 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 46 | // "typeRoots": [], /* List of folders to include type definitions from. */ 47 | // "types": [], /* Type declaration files to be included in compilation. */ 48 | "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 49 | // "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 50 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 51 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 52 | "resolveJsonModule": true, /* Include modules imported with '.json' extension. */ 53 | /* Source Map Options */ 54 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 55 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 56 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 57 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 58 | /* Experimental Options */ 59 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 60 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 61 | /* Advanced Options */ 62 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 63 | "forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */ 64 | }, 65 | "include": [ 66 | "./integration-tests", 67 | "./unit-tests", 68 | "./*.js", 69 | ], 70 | "references": [ 71 | { 72 | "path": "./src" 73 | }, 74 | ], 75 | } 76 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | acmeAccountPrivateJWKPath, 3 | acmeAltNames, 4 | acmeChallengeDir, 5 | acmeCommonName, 6 | acmeDir, 7 | acmeDirectoryURI, 8 | acmeServerNames, 9 | acmeVerifyProviderHTTPS, 10 | areEqualSets, 11 | createCsr, 12 | getVariable, 13 | joinPaths, 14 | readCertificateInfo, 15 | readOrCreateAccountKey, 16 | readCert, 17 | readKey, 18 | toPEM, 19 | KEY_SUFFIX, 20 | CERTIFICATE_SUFFIX, 21 | CERTIFICATE_REQ_SUFFIX, 22 | purgeCachedCertKey, 23 | } from './utils' 24 | import { AcmeClient } from './client' 25 | import fs from 'fs' 26 | import { LogLevel, Logger } from './logger' 27 | 28 | // TODO: make this configurable 29 | const RENEWAL_THRESHOLD_DAYS = 30 30 | 31 | const log = new Logger() 32 | 33 | async function clientAutoMode(r: NginxPeriodicSession): Promise { 34 | const result = await clientAutoModeInternal(r) 35 | if (!result.success) { 36 | log.error( 37 | `clientAutoModeInternal returned an error: ${JSON.stringify(result.info)}` 38 | ) 39 | } 40 | return result.success 41 | } 42 | 43 | type clientAutoModeReturnType = { 44 | success: boolean 45 | info: Record 46 | } 47 | 48 | /** 49 | * Method to use if you want to be able to trigger a certificate refresh from an HTTP request. 50 | * 51 | * 52 | * @param {NginxHTTPRequest} r Incoming session or request 53 | * @returns void 54 | */ 55 | async function clientAutoModeHTTP(r: NginxHTTPRequest): Promise { 56 | try { 57 | const result = await clientAutoModeInternal(r) 58 | if (!result.success) { 59 | log.error( 60 | `clientAutoModeInternal returned an error: ${JSON.stringify( 61 | result.info 62 | )}` 63 | ) 64 | } 65 | return r.return(result.success ? 200 : 500, JSON.stringify(result.info)) 66 | } catch (e) { 67 | log.error('ERROR: ' + JSON.stringify(e)) 68 | return r.return(500, JSON.stringify({ error: e })) 69 | } 70 | } 71 | 72 | /** 73 | * An automated workflow to issue a new certificate for `njs_acme_server_names` 74 | * 75 | * @param {NginxPeriodicSession | NginxHTTPRequest} r Incoming session or request 76 | * @returns ClientAutoModeReturnType 77 | */ 78 | async function clientAutoModeInternal( 79 | r: NginxPeriodicSession | NginxHTTPRequest 80 | ): Promise { 81 | const log = new Logger('auto') 82 | const prefix = acmeDir(r) 83 | const commonName = acmeCommonName(r) 84 | const altNames = acmeAltNames(r) 85 | const retVal: clientAutoModeReturnType = { 86 | success: false, 87 | info: {}, 88 | } 89 | 90 | const pkeyPath = joinPaths(prefix, commonName + KEY_SUFFIX) 91 | const tempPkeyPath = pkeyPath + '.tmp' 92 | const csrPath = joinPaths(prefix, commonName + CERTIFICATE_REQ_SUFFIX) 93 | const certPath = joinPaths(prefix, commonName + CERTIFICATE_SUFFIX) 94 | 95 | let email 96 | try { 97 | email = getVariable(r, 'njs_acme_account_email') 98 | } catch { 99 | retVal.info.error = 100 | "Nginx variable '$njs_acme_account_email' or 'NJS_ACME_ACCOUNT_EMAIL' environment variable must be set" 101 | return retVal 102 | } 103 | 104 | let certificatePem 105 | let pkeyPem 106 | let renewCertificate = false 107 | let certInfo 108 | try { 109 | const certData = fs.readFileSync(certPath, 'utf8') 110 | const privateKeyData = fs.readFileSync(pkeyPath, 'utf8') 111 | 112 | certInfo = await readCertificateInfo(certData) 113 | 114 | const configDomains = acmeServerNames(r) 115 | const certDomains = certInfo.domains.altNames // altNames includes the common name 116 | 117 | if (!areEqualSets(certDomains, configDomains)) { 118 | log.info( 119 | `Renewing certificate because the hostnames in the certificate (${certDomains.join( 120 | ', ' 121 | )}) do not match the configured njs_acme_server_names (${configDomains.join( 122 | ',' 123 | )})` 124 | ) 125 | renewCertificate = true 126 | } else { 127 | // Calculate the date RENEWAL_THRESHOLD_DAYS before the certificate expiration 128 | const renewalThreshold = new Date(certInfo.notAfter) 129 | renewalThreshold.setDate( 130 | renewalThreshold.getDate() - RENEWAL_THRESHOLD_DAYS 131 | ) 132 | 133 | const currentDate = new Date() 134 | if (currentDate > renewalThreshold) { 135 | log.info( 136 | `Renewing certificate because the current certificate expires within the renewal threshold of ${RENEWAL_THRESHOLD_DAYS} days.` 137 | ) 138 | renewCertificate = true 139 | } else { 140 | certificatePem = certData 141 | pkeyPem = privateKeyData 142 | } 143 | } 144 | } catch { 145 | renewCertificate = true 146 | } 147 | 148 | if (renewCertificate) { 149 | const accountKey = await readOrCreateAccountKey( 150 | acmeAccountPrivateJWKPath(r) 151 | ) 152 | // Create a new ACME client 153 | const client = new AcmeClient({ 154 | directoryUrl: acmeDirectoryURI(r), 155 | accountKey: accountKey, 156 | }) 157 | client.api.setVerify(acmeVerifyProviderHTTPS(r)) 158 | 159 | // Create a new CSR 160 | const csr = await createCsr({ 161 | commonName, 162 | altNames, 163 | emailAddress: email, 164 | }) 165 | fs.writeFileSync(csrPath, toPEM(csr.pkcs10Ber, 'CERTIFICATE REQUEST')) 166 | 167 | const privKey = (await crypto.subtle.exportKey( 168 | 'pkcs8', 169 | csr.keys.privateKey 170 | )) as ArrayBuffer 171 | pkeyPem = toPEM(privKey, 'PRIVATE KEY') 172 | fs.writeFileSync(tempPkeyPath, pkeyPem) 173 | log.info(`Wrote private key to ${tempPkeyPath}`) 174 | 175 | const challengePath = acmeChallengeDir(r) 176 | 177 | try { 178 | fs.mkdirSync(challengePath, { recursive: true }) 179 | } catch (e) { 180 | retVal.info.error = `Error creating directory to store challenges. Ensure the ${challengePath} directory is writable by the nginx user.` 181 | return retVal 182 | } 183 | 184 | certificatePem = await client.auto({ 185 | csr: Buffer.from(csr.pkcs10Ber), 186 | email, 187 | termsOfServiceAgreed: true, 188 | challengeCreateFn: async (_, challenge, keyAuthorization) => { 189 | log.info( 190 | `Writing challenge file so nginx can serve it via .well-known/acme-challenge/${challenge.token}` 191 | ) 192 | const path = joinPaths(challengePath, challenge.token) 193 | fs.writeFileSync(path, keyAuthorization) 194 | }, 195 | challengeRemoveFn: async (_authz, challenge, _keyAuthorization) => { 196 | const path = joinPaths(challengePath, challenge.token) 197 | try { 198 | fs.unlinkSync(path) 199 | log.info(`Removed challenge ${path}`) 200 | } catch (e) { 201 | log.error(`Failed to remove challenge ${path}`) 202 | } 203 | }, 204 | }) 205 | certInfo = await readCertificateInfo(certificatePem) 206 | fs.writeFileSync(certPath, certificatePem) 207 | log.info(`Wrote certificate to ${certPath}`) 208 | fs.renameSync(tempPkeyPath, pkeyPath) 209 | log.info(`Renamed ${tempPkeyPath} to ${pkeyPath}`) 210 | 211 | // Purge the cert/key in the shared dict zone if applicable 212 | purgeCachedCertKey(r) 213 | } 214 | 215 | retVal.success = true 216 | retVal.info.certificate = certInfo 217 | retVal.info.renewedCertificate = renewCertificate.toString() 218 | 219 | return retVal 220 | } 221 | 222 | /** 223 | * Retrieves the cert based on the Nginx HTTP request. 224 | * @param {NginxHTTPRequest} r - The Nginx HTTP request object. 225 | * @returns {string, string} - The path and cert associated with the server name. 226 | */ 227 | function js_cert(r: NginxHTTPRequest): string { 228 | return readCert(r) 229 | } 230 | 231 | /** 232 | * Retrieves the key based on the Nginx HTTP request. 233 | * @param {NginxHTTPRequest} r - The Nginx HTTP request object. 234 | * @returns {string} - The path and key associated with the server name. 235 | */ 236 | function js_key(r: NginxHTTPRequest): string { 237 | return readKey(r) 238 | } 239 | 240 | /** 241 | * Demonstrates using js_content to serve challenge responses. 242 | * @param {NginxHTTPRequest} the request 243 | */ 244 | async function challengeResponse(r: NginxHTTPRequest): Promise { 245 | const challengeUriPrefix = '/.well-known/acme-challenge/' 246 | 247 | // Only support GET requests 248 | if (r.method !== 'GET') { 249 | return r.return(400, 'Bad Request') 250 | } 251 | 252 | // Here is the challenge token spec: 253 | // https://datatracker.ietf.org/doc/html/draft-ietf-acme-acme-07#section-8.3 254 | // - greater than 128 bits or ~22 base-64 encoded characters. 255 | // Let's Encrypt uses a 43-character string. 256 | // - base64url character set only 257 | 258 | // Ensure we're not given a token that is too long (128 chars to be future-proof) 259 | if (r.uri.length > 128 + challengeUriPrefix.length) { 260 | return r.return(400, 'Bad Request') 261 | } 262 | 263 | // Ensure this handler is only receiving /.well-known/acme-challenge/ 264 | // requests, and not other requests through some kind of configuration 265 | // mistake. 266 | if (!r.uri.startsWith(challengeUriPrefix)) { 267 | return r.return(400, 'Bad Request') 268 | } 269 | 270 | const token = r.uri.substring(challengeUriPrefix.length) 271 | 272 | // Token must only contain base64url chars 273 | if (token.match(/[^a-zA-Z0-9-_]/)) { 274 | return r.return(400, 'Bad Request') 275 | } 276 | 277 | try { 278 | return r.return( 279 | 200, 280 | // just return the contents of the token file 281 | fs.readFileSync(joinPaths(acmeChallengeDir(r), token), 'utf8') 282 | ) 283 | } catch (e) { 284 | return r.return(404, 'Not Found') 285 | } 286 | } 287 | 288 | export default { 289 | js_cert, 290 | js_key, 291 | challengeResponse, 292 | clientAutoModeHTTP, 293 | clientAutoMode, 294 | LogLevel, 295 | } 296 | -------------------------------------------------------------------------------- /src/x509.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-fallthrough */ 2 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 3 | /** 4 | * this is from https://github.com/nginx/njs-examples/blob/master/njs/http/certs/js/x509.js 5 | * 6 | * 7 | */ 8 | 9 | function asn1_parse_oid(buf) { 10 | var oid = [] 11 | var sid = 0 12 | var cur_octet = buf[0] 13 | 14 | if (cur_octet < 40) { 15 | oid.push(0) 16 | oid.push(cur_octet) 17 | } else if (cur_octet < 80) { 18 | oid.push(1) 19 | oid.push(cur_octet - 40) 20 | } else { 21 | oid.push(2) 22 | oid.push(cur_octet - 80) 23 | } 24 | 25 | for (var n = 1; n < buf.length; n++) { 26 | cur_octet = buf[n] 27 | 28 | if (cur_octet < 0x80) { 29 | sid += cur_octet 30 | 31 | if (sid > Number.MAX_SAFE_INTEGER) throw 'Too big SID value: ' + sid 32 | 33 | oid.push(sid) 34 | sid = 0 35 | } else { 36 | sid += cur_octet & 0x7f 37 | sid <<= 7 38 | 39 | if (sid > Number.MAX_SAFE_INTEGER) throw 'Too big SID value: ' + sid 40 | } 41 | } 42 | 43 | if (buf.slice(-1)[0] >= 0x80) 44 | throw 'Last octet in oid buffer has highest bit set to 1' 45 | 46 | return oid.join('.') 47 | } 48 | 49 | function asn1_parse_integer(buf) { 50 | if (buf.length > 6) { 51 | // may exceed MAX_SAFE_INTEGER, lets return hex 52 | return asn1_parse_any(buf) 53 | } 54 | 55 | var value = 0 56 | var is_negative = false 57 | 58 | if (buf[0] & 0x80) { 59 | is_negative = true 60 | value = buf[0] & 0x7f 61 | var compl_int = 1 << (8 * buf.length - 1) 62 | } else { 63 | value = buf[0] 64 | } 65 | 66 | if (buf.length > 1) { 67 | for (var n = 1; n < buf.length; n++) { 68 | value <<= 8 69 | value += buf[n] 70 | } 71 | } 72 | 73 | if (is_negative) return value - compl_int 74 | else return value 75 | } 76 | 77 | function asn1_parse_ascii_string(buf) { 78 | return buf.toString() 79 | } 80 | 81 | function asn1_parse_ia5_string(buf) { 82 | if (is_ia5(buf)) return buf.toString() 83 | else throw 'Not a IA5String: ' + buf 84 | } 85 | 86 | function asn1_parse_utf8_string(buf) { 87 | return buf.toString('utf8') 88 | } 89 | 90 | function asn1_parse_bmp_string(buf) { 91 | return asn1_parse_any(buf) 92 | } 93 | 94 | function asn1_parse_universal_string(buf) { 95 | return asn1_parse_any(buf) 96 | } 97 | 98 | function asn1_parse_bit_string(buf) { 99 | if (buf[0] == 0) return buf.slice(1).toString('hex') 100 | 101 | var shift = buf[0] 102 | if (shift > 7) throw 'Incorrect shift in bitstring: ' + shift 103 | 104 | var value = '' 105 | var upper_bits = 0 106 | var symbol = '' 107 | 108 | // shift string right and convert to hex 109 | for (var n = 1; n < buf.length; n++) { 110 | var char_code = buf[n] >> (shift + upper_bits) 111 | symbol = char_code.toString(16) 112 | upper_bits = (buf[n] << shift) & 0xff 113 | value += symbol 114 | } 115 | 116 | return value 117 | } 118 | 119 | function asn1_parse_octet_string(buf) { 120 | return asn1_parse_any(buf) 121 | } 122 | 123 | function asn1_parse_any(buf) { 124 | return buf.toString('hex') 125 | } 126 | 127 | function is_ia5(buf) { 128 | for (var n = 0; n < buf.length; n++) { 129 | var s = buf[n] 130 | if (s > 0x7e) return false 131 | } 132 | 133 | return true 134 | } 135 | 136 | function asn1_read_length(buf, pointer) { 137 | var s = buf[pointer] 138 | if (s == 0x80 || s == 0xff) throw 'indefinite length is not supported' 139 | 140 | if (s < 0x80) { 141 | // length is less than 128 142 | pointer++ 143 | return [s, pointer] 144 | } else { 145 | var l = s & 0x7f 146 | if (l > 7) throw 'Too big length, exceeds MAX_SAFE_INTEGER: ' + l 147 | 148 | if (pointer + l >= buf.length) 149 | throw 'Went out of buffer: ' + (pointer + l) + ' ' + buf.length 150 | 151 | var length = 0 152 | for (var n = 0; n < l; n++) { 153 | length += Math.pow(256, l - n - 1) * buf[++pointer] 154 | if (n == 6 && buf[pointer] > 0x1f) 155 | throw 'Too big length, exceeds MAX_SAFE_INTEGER' 156 | } 157 | 158 | return [length, pointer + 1] 159 | } 160 | } 161 | 162 | function asn1_parse_primitive(cls, tag, buf) { 163 | if (cls == 0) { 164 | switch (tag) { 165 | // INTEGER 166 | case 0x02: 167 | return asn1_parse_integer(buf) 168 | // BIT STRING 169 | case 0x03: 170 | try { 171 | return asn1_read(buf) 172 | } catch (e) { 173 | return asn1_parse_bit_string(buf) 174 | } 175 | // OCTET STRING 176 | case 0x04: 177 | try { 178 | return asn1_read(buf) 179 | } catch (e) { 180 | return asn1_parse_octet_string(buf) 181 | } 182 | // OBJECT IDENTIFIER 183 | case 0x06: 184 | return asn1_parse_oid(buf) 185 | // UTF8String 186 | case 0x0c: 187 | return asn1_parse_utf8_string(buf) 188 | // TIME 189 | case 0x0e: 190 | // NumericString 191 | case 0x12: 192 | // PrintableString 193 | case 0x13: 194 | // T61String 195 | case 0x14: 196 | // VideotexString 197 | case 0x15: 198 | return asn1_parse_ascii_string(buf) 199 | // IA5String 200 | case 0x16: 201 | return asn1_parse_ia5_string(buf) 202 | // UTCTime 203 | case 0x17: 204 | // GeneralizedTime 205 | case 0x18: 206 | // GraphicString 207 | case 0x19: 208 | // VisibleString 209 | case 0x1a: 210 | // GeneralString 211 | case 0x1b: 212 | return asn1_parse_ascii_string(buf) 213 | // UniversalString 214 | case 0x1c: 215 | return asn1_parse_universal_string(buf) 216 | // CHARACTER STRING 217 | case 0x1d: 218 | return asn1_parse_ascii_string(buf) 219 | // BMPString 220 | case 0x1e: 221 | return asn1_parse_bmp_string(buf) 222 | // DATE 223 | case 0x1f: 224 | // TIME-OF-DAY 225 | case 0x20: 226 | // DATE-TIME 227 | case 0x21: 228 | // DURATION 229 | case 0x22: 230 | return asn1_parse_ascii_string(buf) 231 | default: 232 | return asn1_parse_any(buf) 233 | } 234 | } else if (cls == 2) { 235 | switch (tag) { 236 | case 0x00: 237 | return asn1_parse_any(buf) 238 | case 0x01: 239 | return asn1_parse_ascii_string(buf) 240 | case 0x02: 241 | return asn1_parse_ascii_string(buf) 242 | case 0x06: 243 | return asn1_parse_ascii_string(buf) 244 | default: 245 | return asn1_parse_any(buf) 246 | } 247 | } 248 | 249 | return asn1_parse_any(buf) 250 | } 251 | 252 | function asn1_read(buf) { 253 | var a = [] 254 | var tag_class 255 | var tag 256 | var pointer = 0 257 | var is_constructed 258 | var s = '' 259 | var length 260 | 261 | while (pointer < buf.length) { 262 | // read type: 7 & 8 bits define class, 6 bit if it is constructed 263 | s = buf[pointer] 264 | tag_class = s >> 6 265 | is_constructed = s & 0x20 266 | tag = s & 0x1f 267 | 268 | if (tag == 0x1f) { 269 | tag = 0 270 | var i = 0 271 | 272 | do { 273 | if (i > 3) throw 'Too big tag value' + tag 274 | 275 | i++ 276 | 277 | if (++pointer >= buf.length) 278 | throw 'Went out of buffer: ' + pointer + ' ' + buf.length 279 | 280 | tag <<= 7 281 | tag += buf[pointer] & 0x7f 282 | } while (buf[pointer] > 0x80) 283 | } 284 | 285 | if (++pointer > buf.length) 286 | throw 'Went out of buffer: ' + pointer + ' ' + buf.length 287 | 288 | var lp = asn1_read_length(buf, pointer) 289 | length = lp[0] 290 | pointer = lp[1] 291 | 292 | if (pointer + length > buf.length) 293 | throw ( 294 | 'length exceeds buf side: ' + length + ' ' + pointer + ' ' + buf.length 295 | ) 296 | 297 | if (is_constructed) { 298 | a.push(asn1_read(buf.slice(pointer, pointer + length))) 299 | } else { 300 | a.push( 301 | asn1_parse_primitive( 302 | tag_class, 303 | tag, 304 | buf.slice(pointer, pointer + length) 305 | ) 306 | ) 307 | } 308 | 309 | pointer += length 310 | } 311 | 312 | return a 313 | } 314 | 315 | function is_oid_exist(cert, oid) { 316 | for (var n = 0; n < cert.length; n++) { 317 | if (Array.isArray(cert[n])) { 318 | if (is_oid_exist(cert[n], oid)) return true 319 | } else { 320 | if (cert[n] == oid) return true 321 | } 322 | } 323 | 324 | return false 325 | } 326 | 327 | // returns all the matching field with the specified 'oid' as a list 328 | function get_oid_value_all(cert, oid) { 329 | var values = [] 330 | 331 | for (var n = 0; n < cert.length; n++) { 332 | if (Array.isArray(cert[n])) { 333 | var r = get_oid_value_all(cert[n], oid) 334 | if (r.length > 0) { 335 | values = values.concat(r) 336 | } 337 | } else { 338 | if (cert[n] == oid) { 339 | if (n < cert.length) { 340 | // push next element in array 341 | values.push(cert[n + 1]) 342 | } 343 | } 344 | } 345 | } 346 | 347 | return values 348 | } 349 | 350 | function get_oid_value(cert, oid) { 351 | for (var n = 0; n < cert.length; n++) { 352 | if (Array.isArray(cert[n])) { 353 | var r = get_oid_value(cert[n], oid) 354 | if (r !== false) return r 355 | } else { 356 | if (cert[n] == oid) { 357 | if (n < cert.length) { 358 | // return next element in array 359 | return cert[n + 1] 360 | } 361 | } 362 | } 363 | } 364 | 365 | return false 366 | } 367 | 368 | function parse_pem_cert(pem) { 369 | var der = pem.split(/\n/) 370 | 371 | if (pem.match('CERTIFICATE')) { 372 | der = der.slice(1, -2) 373 | } 374 | 375 | // eslint-disable-next-line no-undef 376 | return asn1_read(Buffer.from(der.join(''), 'base64')) 377 | } 378 | 379 | export default { 380 | asn1_read, 381 | parse_pem_cert, 382 | is_oid_exist, 383 | get_oid_value, 384 | get_oid_value_all, 385 | } 386 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![CI](https://github.com/nginx/njs-acme/actions/workflows/ci.yaml/badge.svg)](https://github.com/nginx/njs-acme/actions/workflows/ci.yaml) 2 | [![Project Status: Concept – Minimal or no implementation has been done yet, or the repository is only intended to be a limited example, demo, or proof-of-concept.](https://www.repostatus.org/badges/latest/concept.svg)](https://www.repostatus.org/#concept) 3 | [![Community Support](https://badgen.net/badge/support/community/cyan?icon=awesome)](https://github.com/nginx/njs-acme/discussions) 4 | 5 | ![NJS + ACME = Certs!](images/banner.png) 6 | 7 | # njs-acme 8 | 9 | This repository provides a JavaScript library to work with [ACME](https://datatracker.ietf.org/doc/html/rfc8555) providers such as Let's Encrypt for [NJS](https://nginx.org/en/docs/njs/). The source code is compatible with the `ngx_http_js_module` runtime. This allows for the automatic generation and renewal of TLS/SSL certificates for NGINX. 10 | 11 | Requires at least `njs-0.8.2`, which is included with NGINX since `nginx-1.25.3`. 12 | 13 | NOTE: Some ACME providers have strict rate limits. Please consult with your provider. For Let's Encrypt refer to their [rate-limits documentation](https://letsencrypt.org/docs/rate-limits/). 14 | 15 | ## Installation 16 | 17 | There are a few ways of using this repo. You can: 18 | * download `acme.js` from the latest [Release](https://github.com/nginx/njs-acme/releases) 19 | * build an ACME-enabled Docker image to replace your existing NGINX image 20 | * use Docker to build the `acme.js` file to use with your NGINX installation 21 | * build `acme.js` using a locally installed Node.js toolkit to use with your NGINX installation 22 | 23 | Each option above is detailed in each section below. 24 | 25 | ### Downloading the Latest Release 26 | You can download the latest released `acme.js` file from the [Releases](https://github.com/nginx/njs-acme/releases) page. Typically you would place this in the path `/usr/lib/nginx/njs_modules/acme.js` in your NGINX server. See the example [nginx.conf](examples/nginx.conf) to see how to integrate it into your NGINX configuration. 27 | 28 | To integrate the downloaded `acme.js` file into a Docker image, you can add the following to your Dockerfile: 29 | ``` 30 | RUN mkdir -p /usr/lib/nginx/njs_modules/ 31 | RUN curl -L -o /usr/lib/nginx/njs_modules/acme.js https://github.com/nginx/njs-acme/releases/download/v1.0.0/acme.js 32 | ``` 33 | 34 | ### Creating a Docker Image 35 | To create an Nginx+NJS+njs-acme Docker image, simply run: 36 | ``` 37 | % make docker-build 38 | ... 39 | => exporting to image 40 | => => exporting layers 41 | => => writing image ... 42 | => => naming to docker.io/nginx/nginx-njs-acme 43 | ``` 44 | This will build an image with a recent version of NGINX, required njs version, and the `acme.js` file installed at `/usr/lib/nginx/njs_modules/`. 45 | 46 | The image will be tagged `nginx/nginx-njs-acme`, where you can use it in place of a standard `nginx` image. 47 | 48 | When running the container, we advise mounting the `/etc/nginx/njs-acme/` directory in a Docker volume so that the cert/key are retained between deployments of your `nginx` container. The `docker-compose.yml` file in this directory shows an example of doing this using the `certs` volume. 49 | 50 | ### Building `acme.js` With Docker 51 | 52 | If you want to use your own NGINX installation and do not want to have to worry about installing Node.js and other build dependencies, then you can run this command: 53 | ``` 54 | make docker-copy 55 | ``` 56 | 57 | This will build the full image and copy the `acme.js` file to the local `dist/` directory. You can then include this file in your NGINX deployments. 58 | 59 | ### Building `acme.js` Without Docker 60 | 61 | If you have Node.js and NPM installed on your computer, you can run this command to generate `acme.js` directly: 62 | ``` 63 | make build 64 | ``` 65 | 66 | This will generate `dist/acme.js`, where you can then integrate it into your existing NGINX / NJS environment. 67 | 68 | ## Configuration Variables 69 | 70 | You can use environment variables _or_ NGINX `js_var` directives to control the behavior of the `njs-acme`. 71 | 72 | In the case where both are defined, environment variables take precedence. Environment variables are in `ALL_CAPS`, whereas the nginx config variable is the same name, just prefixed with a dollar sign and `$lower_case`. 73 | 74 | For example, `NJS_ACME_SERVER_NAMES` (env var) is the same as `$njs_acme_server_names` (js_var). 75 | 76 | ### Staging by Default 77 | 78 | The value of the variable `NJS_ACME_DIRECTORY_URI` (`js_var $njs_acme_directory_uri`) defaults to Let's Encrypt's _Staging_ environment. When you are finished testing with their staging environment, you will need to define/change the value of this to your ACME provider's production environment. In Let's Encrypt's case the production URL is `https://acme-v02.api.letsencrypt.org/directory`. 79 | 80 | You will need to remove the staging certificate from your NGINX server's filesystem when changing from staging to production. It is located in `/etc/nginx/njs-acme/` by default (controlled by the variable `NJS_ACME_DIR`). 81 | 82 | ### Required Variables 83 | 84 | - `NJS_ACME_ACCOUNT_EMAIL` (env)\ 85 | `$njs_acme_account_email` (js_var)\ 86 | Your email address to send to the ACME provider.\ 87 | value: Any valid email address\ 88 | default: none (you must specify this!) 89 | 90 | - `NJS_ACME_SERVER_NAMES` (env)\ 91 | `$njs_acme_server_names` (js_var)\ 92 | The hostname or list of hostnames to request the certificate for.\ 93 | value: Space-separated list of hostnames, e.g. `www1.mydomain.com www2.mydomain.com`\ 94 | default: none (you must specify this!) 95 | 96 | ### Optional Variables 97 | - `NJS_ACME_VERIFY_PROVIDER_HTTPS` (env)\ 98 | `$njs_acme_verify_provider_https` (js_var)\ 99 | Verify the ACME provider certificate when connecting. 100 | ``` 101 | value: false | true 102 | default: true 103 | ``` 104 | 105 | - `NJS_ACME_DIRECTORY_URI` (env)\ 106 | `$njs_acme_directory_uri` (js_var)\ 107 | ACME directory URL. 108 | ``` 109 | value: {Any valid URL} 110 | default: https://acme-staging-v02.api.letsencrypt.org/directory 111 | ``` 112 | 113 | - `NJS_ACME_DIR` (env)\ 114 | `$njs_acme_dir` (js_var)\ 115 | Path to store ACME-related files such as keys, certificate requests, certificates, etc. 116 | ``` 117 | value: Any valid system path writable by the `nginx` user. 118 | default: /etc/nginx/njs-acme/ 119 | ``` 120 | 121 | - `NJS_ACME_CHALLENGE_DIR` (env)\ 122 | `$njs_acme_challenge_dir` (js_var)\ 123 | Path to store ACME-related challenge responses. 124 | ``` 125 | value: Any valid system path writable by the `nginx` user. 126 | default: `${NJS_ACME_DIR}/challenge/` 127 | ``` 128 | 129 | - `NJS_ACME_ACCOUNT_PRIVATE_JWK` (env)\ 130 | `$njs_acme_account_private_jwk` (js_var)\ 131 | Path to fetch/store the account private JWK. 132 | ``` 133 | value: Path to the private JWK 134 | default: ${NJS_ACME_DIR}/account_private_key.json 135 | ``` 136 | 137 | - `NJS_ACME_SHARED_DICT_ZONE_NAME` (env)\ 138 | `$njs_acme_shared_dict_zone_name` (js_var)\ 139 | [Shared Dictionary Zone](https://nginx.org/en/docs/http/ngx_http_js_module.html#js_shared_dict_zone) name . 140 | ``` 141 | value: Zone name used as in `js_shared_dict_zone` directive 142 | default: acme` 143 | ``` 144 | 145 | ## NGINX Configuration 146 | 147 | There are a few pieces that are required to be present in your `nginx.conf` file. The file at [`examples/nginx.conf`](./examples/nginx.conf) shows them all. 148 | 149 | NOTE: The examples here use `js_var` for configuration variables, but keep in mind you can use the equivalent environment variables instead if that works better in your environment. See the Configuration Variables section above for specifics. 150 | 151 | ### `nginx.conf` Root 152 | * Ensures the NJS module is loaded. 153 | ```nginx 154 | load_module modules/ngx_http_js_module.so; 155 | ``` 156 | 157 | ### `http` Section 158 | * Adds the NJS module directory to the search path. 159 | ```nginx 160 | js_path "/usr/lib/nginx/njs_modules/"; 161 | ``` 162 | * Ensures a root certificate bundle is loaded into NJS. 163 | ```nginx 164 | js_fetch_trusted_certificate /etc/ssl/certs/ISRG_Root_X1.pem; 165 | ``` 166 | * Load `acme.js` into the `acme` namespace. 167 | ```nginx 168 | js_import acme from acme.js; 169 | ``` 170 | * Configure a DNS resolver for NJS to use. 171 | ```nginx 172 | resolver 127.0.0.11 ipv6=off; # docker-compose 173 | ``` 174 | * Configure a [Shared Dictionary Zone](https://nginx.org/en/docs/http/ngx_http_js_module.html#js_shared_dict_zone) to use.\ 175 | Set zone size to be enough to store all certs and keys. e.g. 1MB should be enough to store 100 certs/keys 176 | ```nginx 177 | js_shared_dict_zone zone=acme:1m 178 | ``` 179 | * NOTE: If you want to use a different `js_shared_dict_zone` name, then you need to define the variable `$njs_acme_shared_dict_zone_name` with the name you would like to use. You can also use the environment variable `NJS_ACME_SHARED_DICT_ZONE_NAME`. 180 | ```nginx 181 | js_var $njs_acme_shared_dict_zone_name acme; 182 | ``` 183 | 184 | ### `server` Section(s) 185 | * Set your email address to use to configure your ACME account. This may also 186 | be defined with the environment variable `NJS_ACME_ACCOUNT_EMAIL`. 187 | ```nginx 188 | js_var $njs_acme_account_email test@example.com; 189 | ``` 190 | * Set the hostname or hostnames (space-separated) to generate the certificate. 191 | This may also be defined with the environment variable `NJS_ACME_SERVER_NAMES`. 192 | ```nginx 193 | js_var $njs_acme_server_names 'proxy.nginx.com proxy2.nginx.com'; 194 | ``` 195 | * Set and use variables to hold the certificate and key paths using Javascript. 196 | ```nginx 197 | js_set $dynamic_ssl_cert acme.js_cert; 198 | js_set $dynamic_ssl_key acme.js_key; 199 | 200 | ssl_certificate data:$dynamic_ssl_cert; 201 | ssl_certificate_key data:$dynamic_ssl_key; 202 | ``` 203 | 204 | ### `location` Blocks 205 | * Location to handle ACME challenge requests. This must be accessible from the ACME server - in most cases this means accessbile from another host on the Internet if you are using a service like Let's Encrypt. 206 | ```nginx 207 | location ~ "^/\.well-known/acme-challenge/[-_A-Za-z0-9]{22,128}$" { 208 | js_content acme.challengeResponse; 209 | } 210 | ``` 211 | * Named location to contain the `js_periodic` directive to automatically request or renew certificates if necessary. 212 | 213 | > NOTE: This runs at the *end* of each interval, so your server will not be ready for a minute. If this is a problem for your use case, see the ALTERNATIVE below. 214 | ```nginx 215 | location @acmePeriodicAuto { 216 | js_periodic acme.clientAutoMode interval=1m; 217 | } 218 | ``` 219 | ALTERNATIVE: The `js_periodic` command runs *after* the interval period has elapsed, not at the beginning. If your use case requires immediate certificate provisioning, then use the following `location {}` block **instead**. This location exposes the endpoint `/acme/auto`, which triggers the certificate provisioning process when requested. 220 | ```nginx 221 | location = /acme/auto { 222 | allow 127.0.0.1; # Adjust for your needs 223 | deny all; 224 | js_periodic acme.clientAutoMode interval=1m; 225 | js_content acme.clientAutoModeHTTP; 226 | } 227 | ``` 228 | 229 | Use one location block or the other. 230 | 231 | ## Development 232 | 233 | This project uses Babel and Rollup to compile TypeScript sources into a single JavaScript file for `njs`. It uses Mocha with nginx-testing for running integration tests against the NGINX server. This project uses [njs-typescript-starter](https://github.com/jirutka/njs-typescript-starter/tree/master) to write NJS modules and integration tests in TypeScript. 234 | 235 | The ACME RESTful client is implemented using [ngx.fetch](http://nginx.org/en/docs/njs/reference.html#ngx_fetch), [crypto API](http://nginx.org/en/docs/njs/reference.html#builtin_crypto), [PKI.js](https://pkijs.org/) APIs in the NJS runtime. 236 | 237 | ### With Docker 238 | 239 | There is a `docker-compose.yml` file in the project root directory that brings up an ACME server, a challenge server, a Node.js container for rebuilding the `acme.js` file when source files change, and an NGINX container. The built `acme.js` file is shared between the Node.js and NGINX containers. The NGINX container will reload when the `acme.js` file changes. 240 | 241 | #### VSCode Devcontainer 242 | 243 | If you use VSCode or another devcontainer-compatible editor, then run the following: 244 | ``` 245 | code . 246 | ``` 247 | 248 | Choose to "Reopen in container" and the services specified in the `docker-compose.yml` file will start. Editing and saving source files will trigger a rebuild of the `acme.js` file, and NGINX will reload its configuration. 249 | 250 | #### Docker Compose 251 | 252 | If you just want to start the development environment using Docker (no devcontainer) then run: 253 | ``` 254 | make docker-devup 255 | ``` 256 | 257 | 258 | ### Without Docker 259 | 260 | To follow these steps, you will need to have Node.js version 14.15 or greater installed on your system. 261 | 262 | 1. Install dependencies: 263 | ``` 264 | npm ci 265 | ``` 266 | 267 | 2. Start the watcher: 268 | ``` 269 | npm run watch 270 | ``` 271 | 272 | 3. Edit the source files. When you save a change, the watcher will rebuild `./dist/acme.js` or display errors. 273 | 274 | 275 | ## Testing 276 | 277 | ### With Docker 278 | 279 | 1. Start a test environment in Docker: 280 | 281 | make docker-devup 282 | 283 | 2. Optionally you can watch for `nginx` log file in a separate shell: 284 | 285 | docker compose logs -f nginx 286 | 287 | 3. When started initially, nginx will not have certificates at all. If you use the [example config](examples/nginx.conf), you will need to wait one minute for the `js_periodic` directive to invoke `acme.clientAutoMode` to create the certificate. 288 | 289 | 4. Send an HTTP request to nginx running in Docker: 290 | 291 | curl -vik --resolve proxy.nginx.com:8000:127.0.0.1 http://proxy.nginx.com:8000/ 292 | 293 | 5. Send an HTTPS request to nginx running in Docker to test a new certificate: 294 | 295 | curl -vik --resolve proxy.nginx.com:4443:127.0.0.1 https://proxy.nginx.com:4443 296 | 297 | 6. Test with `openssl`: 298 | 299 | openssl s_client -servername proxy.nginx.com -connect localhost:4443 -showcerts 300 | 301 | 7. Display content of certificates 302 | 303 | docker compose exec -it nginx ls -la /etc/nginx/njs-acme/ 304 | 305 | The [docker-compose](./docker-compose.yml) file uses volumes to persist artifacts (account keys, certificate, keys). Additionally, [letsencrypt/pebble](https://github.com/letsencrypt/pebble) is used for testing in Docker, so you don't need to open up port 80 for challenge validation. 306 | 307 | 308 | ## Build Your Own Flows 309 | 310 | If the reference implementation does not meet your needs, then you can build your own flows using this project as a library of convenience functions. 311 | 312 | Look at `clientAutoMode` in [`src/index.ts`](./src/index.ts) to see how you can use the convenience functions to build a ACME client implementation. There are some additional methods in [`src/examples.ts`](./src/examples.ts) showing how to use the ACME account creation APIs or generating Certificate Signing Requests on demand. 313 | 314 | ## Project Structure 315 | 316 | | Path | Description | 317 | | ---- | ------------| 318 | | [src](src) | Contains your source code that will be compiled to the `dist/` directory. | 319 | | [integration-tests](integration-tests) | Integration tests. | 320 | | [unit-tests](unit-tests) | Unit tests for code in `src/`. | 321 | 322 | ## Contributing 323 | 324 | Please see the [contributing guide](https://github.com/nginx/njs-acme/blob/main/CONTRIBUTING.md) for guidelines on how to best contribute to this project. 325 | 326 | ## License 327 | 328 | [Apache License, Version 2.0](https://github.com/nginx/njs-acme/blob/main/LICENSE) 329 | 330 | © [F5, Inc.](https://www.f5.com/) 2023 331 | -------------------------------------------------------------------------------- /src/api.ts: -------------------------------------------------------------------------------- 1 | import { 2 | formatResponseError, 3 | getPublicJwk, 4 | RsaPublicJwk, 5 | EcdsaPublicJwk, 6 | } from './utils' 7 | import { Logger, LogLevel } from './logger' 8 | import { version } from '../package.json' 9 | import { 10 | AccountCreateRequest, 11 | AccountUpdateRequest, 12 | ClientExternalAccountBindingOptions, 13 | OrderCreateRequest, 14 | } from './client' 15 | import OGCrypto from 'crypto' 16 | 17 | export type AcmeMethod = 'GET' | 'HEAD' | 'POST' | 'POST-as-GET' 18 | export type AcmeResource = 19 | | 'newNonce' 20 | | 'newAccount' 21 | | 'newAuthz' 22 | | 'newOrder' 23 | | 'revokeCert' 24 | | 'keyChange' 25 | | 'renewalInfo' 26 | export type AcmeSignAlgo = 'RS256' | 'ES256' | 'ES512' | 'ES384' 27 | 28 | export interface SignedPayload { 29 | payload: string 30 | protected: string 31 | signature?: string 32 | externalAccountBinding?: ClientExternalAccountBindingOptions 33 | } 34 | 35 | type ApiPayload = 36 | | Record 37 | | AccountCreateRequest 38 | | OrderCreateRequest 39 | | SignedPayload 40 | | RsaPublicJwk 41 | | EcdsaPublicJwk 42 | | null 43 | | undefined 44 | 45 | export type UpdateAuthorizationData = { 46 | status: string 47 | } 48 | 49 | export interface DirectoryMetadata { 50 | /** 51 | * A URL identifying the current terms of service 52 | */ 53 | termsOfService?: string 54 | 55 | /** 56 | * An HTTP or HTTPS URL locating a website providing more information 57 | * about the ACME server 58 | */ 59 | website?: string 60 | 61 | /** 62 | * The hostnames that the ACME server recognizes as referring to itself 63 | * for the purposes of CAA record validation 64 | * 65 | * NOTE: 66 | * Each string MUST represent the same sequence of ASCII code points 67 | * that the server will expect to see as the "Issuer Domain Name" 68 | * in a CAA issue or issue wild property tag. This allows clients 69 | * to determine the correct issuer domain name to use 70 | * when configuring CAA records 71 | */ 72 | caaIdentities?: string[] 73 | 74 | /** 75 | * If this field is present and set to "true", then the CA requires 76 | * that all newAccount requests include an "externalAccountBinding" 77 | * field associating the new account with an external account 78 | */ 79 | externalAccountRequired?: boolean 80 | 81 | /** 82 | * 83 | */ 84 | endpoints?: string[] 85 | } 86 | 87 | export interface AcmeDirectory { 88 | /** 89 | * New nonce. 90 | */ 91 | newNonce: string 92 | 93 | /** 94 | * New account. 95 | */ 96 | newAccount: string 97 | 98 | /** 99 | * New authorization 100 | */ 101 | newAuthz?: string 102 | 103 | /** 104 | * New order. 105 | */ 106 | newOrder: string 107 | 108 | /** 109 | * Revoke certificate 110 | */ 111 | revokeCert: string 112 | 113 | /** 114 | * Key change 115 | */ 116 | keyChange: string 117 | 118 | /** 119 | * Metadata object 120 | */ 121 | meta?: DirectoryMetadata 122 | 123 | /** 124 | * draft-ietf-acme-ari-00 125 | */ 126 | renewalInfo?: string 127 | } 128 | 129 | /** 130 | * Directory URLs for various ACME providers 131 | */ 132 | export const directories = { 133 | buypass: { 134 | staging: 'https://api.test4.buypass.no/acme/directory', 135 | production: 'https://api.buypass.com/acme/directory', 136 | }, 137 | letsencrypt: { 138 | staging: 'https://acme-staging-v02.api.letsencrypt.org/directory', 139 | production: 'https://acme-v02.api.letsencrypt.org/directory', 140 | }, 141 | zerossl: { 142 | production: 'https://acme.zerossl.com/v2/DV90', 143 | }, 144 | pebble: { 145 | // Let's encrypt for testing https://github.com/letsencrypt/pebble 146 | staging: 'https://localhost:14000/dir', 147 | }, 148 | } 149 | 150 | /** 151 | * ACME HTTP client 152 | * 153 | * @class HttpClient 154 | * @param directoryUrl {string} URL to the ACME directory. 155 | * @param accountKey {CryptoKey} Private key to use for signing requests. 156 | * @param accountUrl [string] (optional) URL of the account, if it has already been registered. 157 | * @param externalAccountBinding [ClientExternalAccountBindingOptions] (optional) External account binding options 158 | * @param verify {boolean} (optional) Enables or disables verification of the HTTPS server certificate while making requests. Defaults to `true`. 159 | * @param debug {boolean} (optional) Enables debug mode. Defaults to `false`. 160 | * @param maxBadNonceRetries {number} (optional) Maximum number of retries when encountering a bad nonce error. Defaults to 5. 161 | */ 162 | export class HttpClient { 163 | /** 164 | * The URL for the ACME directory. 165 | * @type {string} 166 | */ 167 | directoryUrl: string 168 | 169 | /** 170 | * The cryptographic key pair used for signing requests. 171 | * @type {CryptoKey} 172 | */ 173 | accountKey: CryptoKey 174 | 175 | /** 176 | * An object that contains external account binding information. 177 | * @type {ClientExternalAccountBindingOptions} 178 | */ 179 | externalAccountBinding: ClientExternalAccountBindingOptions 180 | 181 | /** 182 | * The ACME directory. 183 | * @type {?AcmeDirectory} 184 | */ 185 | directory: AcmeDirectory | null 186 | 187 | /** 188 | * The public key in JWK format. 189 | * @type {?RsaPublicJwk | ?EcdsaPublicJwk} 190 | */ 191 | jwk: RsaPublicJwk | EcdsaPublicJwk | null | undefined 192 | 193 | /** 194 | * The URL for the ACME account. 195 | * @type {?string} 196 | */ 197 | accountUrl: string | null | undefined 198 | 199 | /** 200 | * Determines whether to verify the HTTPS server certificate while making requests. 201 | * @type {boolean} 202 | */ 203 | verify: boolean 204 | 205 | /** 206 | * The maximum number of retries allowed when encountering a bad nonce. 207 | * @type {number} 208 | */ 209 | maxBadNonceRetries: number 210 | 211 | private readonly log: Logger 212 | 213 | /** 214 | * Creates an instance of the ACME HTTP client. 215 | * @constructor 216 | * @param {string} directoryUrl - The URL of the ACME directory. 217 | * @param {CryptoKey} accountKey - The private key to use for ACME account operations. 218 | * @param {string} [accountUrl=""] - The URL of the ACME account. If not provided, a new account will be created. 219 | * @param {ClientExternalAccountBindingOptions} [externalAccountBinding={ kid: "", hmacKey: "" }] - The external account binding options for the client. 220 | * @returns {HttpClient} The newly created instance of the ACME HTTP client. 221 | */ 222 | constructor( 223 | directoryUrl: string, 224 | accountKey: CryptoKey, 225 | accountUrl = '', 226 | externalAccountBinding: ClientExternalAccountBindingOptions = { 227 | kid: '', 228 | hmacKey: '', 229 | } 230 | ) { 231 | this.directoryUrl = directoryUrl 232 | this.accountKey = accountKey 233 | this.externalAccountBinding = externalAccountBinding 234 | 235 | this.directory = null 236 | this.jwk = null 237 | this.accountUrl = accountUrl 238 | this.verify = true 239 | this.maxBadNonceRetries = 5 240 | this.log = new Logger('http', LogLevel.Info) 241 | } 242 | 243 | /** 244 | * HTTP request 245 | * 246 | * @param {string} url HTTP URL 247 | * @param {string} method HTTP method 248 | * @param {string} [body] Request options 249 | * @returns {Promise} HTTP response 250 | */ 251 | async request(url: string, method: AcmeMethod, body = ''): Promise { 252 | const options: NgxFetchOptions = { 253 | headers: { 254 | 'user-agent': `njs-acme-v${version}`, 255 | 'Content-Type': 'application/jose+json', 256 | }, 257 | method: method, 258 | body: body, 259 | verify: this.verify || false, 260 | } 261 | 262 | /* Request */ 263 | this.log.debug('Sending a new request:', method, url, options) 264 | const resp = await ngx.fetch(url, options) 265 | this.log.debug('Got a response:', resp.status, method, url, resp.headers) 266 | return resp 267 | } 268 | 269 | /** 270 | * Sends a signed request to the specified URL with the provided payload. 271 | * https://tools.ietf.org/html/rfc8555#section-6.2 272 | * 273 | * @async 274 | * @param {string} url - The URL to send the request to. 275 | * @param {object} payload - The request payload to send. 276 | * @param {object} options - An object containing optional parameters. 277 | * @param {string} [options.kid=null] - The kid parameter for the request. 278 | * @param {string} [options.nonce=null] - The nonce parameter for the request. 279 | * @param {boolean} [options.includeExternalAccountBinding=false] - Whether to include the externalAccountBinding parameter in the request. 280 | * @param {number} [attempts=0] - The number of times the request has been attempted. 281 | * @returns {Promise} A Promise that resolves with the Response object for the request. 282 | */ 283 | async signedRequest( 284 | url: string, 285 | payload: ApiPayload, 286 | { 287 | kid = null as string | null, 288 | nonce = null as string | null, 289 | includeExternalAccountBinding = false, 290 | } = {}, 291 | attempts = 0 292 | ): Promise { 293 | if (!nonce) { 294 | nonce = await this.getNonce() 295 | } 296 | if (!this.jwk) { 297 | await this.getJwk() 298 | } 299 | 300 | this.log.debug('Signing request with:', { kid, nonce, jwt: this.jwk }) 301 | 302 | /* External account binding 303 | 304 | https://datatracker.ietf.org/doc/html/rfc8555#section-7.3.4 305 | 306 | */ 307 | if (includeExternalAccountBinding && this.externalAccountBinding) { 308 | if ( 309 | this.externalAccountBinding.kid && 310 | this.externalAccountBinding.hmacKey 311 | ) { 312 | const jwk = this.jwk 313 | const eabKid = this.externalAccountBinding.kid 314 | const eabHmacKey = this.externalAccountBinding.hmacKey 315 | 316 | // FIXME 317 | if (payload) { 318 | payload.externalAccountBinding = this.createSignedHmacBody( 319 | eabHmacKey, 320 | url, 321 | jwk, 322 | { kid: eabKid } 323 | ) 324 | } 325 | } 326 | } 327 | 328 | /* Sign body and send request */ 329 | const data = await this.createSignedBody(url, payload, { nonce, kid }) 330 | this.log.debug('Signed request body:', data) 331 | const resp = await this.request(url, 'POST', JSON.stringify(data)) 332 | 333 | if (resp.status === 400) { 334 | // Retry on bad nonce - https://tools.ietf.org/html/draft-ietf-acme-acme-10#section-6.4 335 | // or bad response code. 336 | if (attempts < this.maxBadNonceRetries) { 337 | nonce = resp.headers.get('replay-nonce') || null 338 | attempts += 1 339 | 340 | this.log.warn( 341 | `Error response from server, retrying (${attempts}/${this.maxBadNonceRetries}) signed request to: ${url}` 342 | ) 343 | return this.signedRequest( 344 | url, 345 | payload, 346 | { kid, nonce, includeExternalAccountBinding }, 347 | attempts 348 | ) 349 | } 350 | } 351 | /* Return response */ 352 | return resp 353 | } 354 | 355 | /** 356 | * Sends a signed ACME API request with optional JWS authentication, nonce handling, and external account binding 357 | * request to the specified URL with the provided payload, and verifies the response status code. 358 | * 359 | * @param {string} url - The URL to make the API request to. 360 | * @param {any} [payload=null] - The payload to include in the API request. 361 | * @param {number[]} [validStatusCodes=[]] - An array of valid HTTP status codes. 362 | * @param {Object} [options={}] - An object of options for the API request. 363 | * @param {boolean} [options.includeJwsKid=true] - Whether to include the JWS kid header in the API request. 364 | * @param {boolean} [options.includeExternalAccountBinding=false] - Whether to include the external account binding in the API request. 365 | * @returns {Promise} - A promise that resolves with the API response. 366 | * @throws {Error} When an unexpected status code is returned in the HTTP response, with the corresponding error message returned in the response body. 367 | */ 368 | async apiRequest( 369 | url: string, 370 | payload: ApiPayload = null, 371 | validStatusCodes: number[] = [], 372 | { includeJwsKid = true, includeExternalAccountBinding = false } = {} 373 | ): Promise { 374 | const kid = includeJwsKid ? this.getAccountUrl() : null 375 | this.log.debug('Preparing a new api request:', { kid, payload }) 376 | const resp = await this.signedRequest(url, payload, { 377 | kid, 378 | includeExternalAccountBinding, 379 | }) 380 | 381 | if ( 382 | validStatusCodes.length && 383 | validStatusCodes.indexOf(resp.status) === -1 384 | ) { 385 | const b = await resp.json() 386 | this.log.warn( 387 | `Received unexpected status code ${resp.status} for API request ${url}.`, 388 | 'Expected status codes:', 389 | validStatusCodes, 390 | 'Body response:', 391 | b 392 | ) 393 | 394 | const e = formatResponseError(b) 395 | throw new Error(e) 396 | } 397 | return resp 398 | } 399 | 400 | /** 401 | * ACME API request by resource name helper 402 | * 403 | * @private 404 | * @param {string} resource Request resource name 405 | * @param {object} [payload] Request payload, default: `null` 406 | * @param {array} [validStatusCodes] Array of valid HTTP response status codes, default: `[]` 407 | * @param {object} [opts] 408 | * @param {boolean} [opts.includeJwsKid] Include KID instead of JWK in JWS header, default: `true` 409 | * @param {boolean} [opts.includeExternalAccountBinding] Include EAB in request, default: `false` 410 | * @returns {Promise} HTTP response 411 | */ 412 | async apiResourceRequest( 413 | resource: AcmeResource, 414 | payload: ApiPayload, 415 | validStatusCodes: number[] = [], 416 | { includeJwsKid = true, includeExternalAccountBinding = false } = {} 417 | ): Promise { 418 | const resourceUrl = await this.getResourceUrl(resource) 419 | return this.apiRequest(resourceUrl, payload, validStatusCodes, { 420 | includeJwsKid, 421 | includeExternalAccountBinding, 422 | }) 423 | } 424 | 425 | /** 426 | * Retrieves the ACME directory from the directory URL specified in the constructor. 427 | * 428 | * @throws {Error} Throws an error if the response status code is not 200 OK or the response body is invalid. 429 | * @returns {Promise} Updates internal `this.directory` and returns a promise 430 | */ 431 | async getDirectory(): Promise { 432 | if (!this.directory) { 433 | const resp = await this.request(this.directoryUrl, 'GET') 434 | 435 | if (resp.status >= 400) { 436 | throw new Error( 437 | `Attempting to read ACME directory returned error ${resp.status}: ${this.directoryUrl}` 438 | ) 439 | } 440 | const data = (await resp.json()) as AcmeDirectory 441 | if (!data) { 442 | throw new Error('Attempting to read ACME directory returned no data') 443 | } 444 | this.directory = data 445 | this.log.debug('Fetched directory:', this.directory) 446 | } 447 | } 448 | 449 | /** 450 | * Retrieves the public key associated with the account key 451 | * 452 | * @async 453 | * @function getJwk 454 | * @returns {Promise} The public key associated with the account key, or null if not found 455 | * @throws {Error} If the account key is not set or is not valid 456 | */ 457 | async getJwk(): Promise { 458 | // singleton 459 | if (!this.jwk) { 460 | this.log.debug( 461 | 'Public JWK not set. Obtaining it from Account Private Key...' 462 | ) 463 | this.jwk = await getPublicJwk(this.accountKey) 464 | this.log.debug('Obtained Account Public JWK:', this.jwk) 465 | } 466 | return this.jwk 467 | } 468 | 469 | /** 470 | * Get nonce from directory API endpoint 471 | * 472 | * https://tools.ietf.org/html/rfc8555#section-7.2 473 | * 474 | * @returns {Promise} nonce 475 | */ 476 | async getNonce(): Promise { 477 | const url = await this.getResourceUrl('newNonce') 478 | const resp = await this.request(url, 'HEAD') 479 | if (!resp.headers.get('replay-nonce')) { 480 | this.log.error('No nonce from ACME provider. "replay-nonce" header found') 481 | throw new Error('Failed to get nonce from ACME provider') 482 | } 483 | return resp.headers.get('replay-nonce') 484 | } 485 | 486 | /** 487 | * Get URL for a directory resource 488 | * 489 | * @param {string} resource API resource name 490 | * @returns {Promise} URL 491 | */ 492 | async getResourceUrl(resource: AcmeResource): Promise { 493 | await this.getDirectory() 494 | 495 | if (this.directory != null && !this.directory[resource]) { 496 | this.log.error( 497 | `Unable to locate API resource URL in ACME directory: "${resource}"` 498 | ) 499 | throw new Error( 500 | `Unable to locate API resource URL in ACME directory: "${resource}"` 501 | ) 502 | } 503 | if (!this.directory) { 504 | throw new Error('this.directory is null') 505 | } 506 | return this.directory[resource] as string 507 | } 508 | 509 | /** 510 | * Get directory meta field 511 | * 512 | * @param {string} field Meta field name 513 | * @returns {Promise} Meta field value 514 | */ 515 | async getMetaField( 516 | field: 517 | | 'termsOfService' 518 | | 'website' 519 | | 'caaIdentities' 520 | | 'externalAccountRequired' 521 | | 'endpoints' 522 | ): Promise { 523 | await this.getDirectory() 524 | if (!this.directory) { 525 | throw new Error('this.directory is null') 526 | } 527 | return this.directory?.meta?.[field] 528 | } 529 | 530 | /** 531 | * Prepares a signed request body to be sent to an ACME server. 532 | * @param {AcmeSignAlgo|string} alg - The signing algorithm to use. 533 | * @param {NjsStringLike} url - The URL to include in the signed payload. 534 | * @param {Object|null} [payload=null] - The payload to include in the signed payload. 535 | * @param {RsaPublicJwk|EcdsaPublicJwk|null|undefined} [jwk=null] - The JWK to use for signing the payload. 536 | * @param {Object} [options={nonce: null, kid: null}] - Additional options for the signed payload. 537 | * @param {string|null} [options.nonce=null] - The nonce to include in the signed payload. 538 | * @param {string|null} [options.kid=null] - The KID to include in the signed payload. 539 | * @returns {SignedPayload} The signed payload. 540 | */ 541 | prepareSignedBody( 542 | alg: AcmeSignAlgo | string, 543 | url: string, 544 | payload: ApiPayload = null, 545 | jwk: RsaPublicJwk | EcdsaPublicJwk | null | undefined, 546 | { nonce = null as string | null, kid = null as string | null } = {} 547 | ): SignedPayload { 548 | const header: Record = { alg, url } 549 | 550 | /* Nonce */ 551 | if (nonce) { 552 | header.nonce = nonce 553 | } 554 | 555 | /* KID or JWK */ 556 | if (kid) { 557 | header.kid = kid 558 | } else { 559 | header.jwk = jwk 560 | } 561 | 562 | /* Body */ 563 | const body: SignedPayload = { 564 | payload: payload 565 | ? Buffer.from(JSON.stringify(payload)).toString('base64url') 566 | : '', 567 | protected: Buffer.from(JSON.stringify(header)).toString('base64url'), 568 | } 569 | return body 570 | } 571 | 572 | /** 573 | * Creates a signed HMAC body for the given URL and payload, with optional nonce and kid parameters 574 | * 575 | * @param {string} hmacKey The key to use for the HMAC signature. 576 | * @param {string} url The URL to sign. 577 | * @param {object} [payload] The payload to sign. Defaults to null. 578 | * @param {object} [opts] Optional parameters for the signature (nonce and kid). 579 | * @param {string} [opts.nonce] The anti-replay nonce to include in the signature. Defaults to null. 580 | * @param {string} [opts.kid] The kid to include in the signature. Defaults to null. 581 | * @returns {object} Signed HMAC request body 582 | * @throws An error if the HMAC key is not provided. 583 | */ 584 | async createSignedHmacBody( 585 | hmacKey: string, 586 | url: string, 587 | payload: ApiPayload = null, 588 | { nonce = null as string | null, kid = null as string | null } = {} 589 | ): Promise { 590 | if (!hmacKey) { 591 | throw new Error('HMAC key is required.') 592 | } 593 | const result = this.prepareSignedBody('HS256', url, payload, null, { 594 | nonce, 595 | kid, 596 | }) 597 | const h = OGCrypto.createHmac('sha256', Buffer.from(hmacKey, 'base64')) 598 | h.update(`${result.protected}.${result.payload}`) 599 | result.signature = h.digest('base64url') 600 | return result 601 | } 602 | 603 | /** 604 | * Create JWS HTTP request body using RSA or ECC 605 | * 606 | * https://datatracker.ietf.org/doc/html/rfc7515 607 | * 608 | * @param {string} url Request URL 609 | * @param {object} [payload] Request payload 610 | * @param {object} [opts] 611 | * @param {string} [opts.nonce] JWS nonce 612 | * @param {string} [opts.kid] JWS KID 613 | * @returns {Promise} JWS request body 614 | */ 615 | async createSignedBody( 616 | url: string, 617 | payload: ApiPayload = null, 618 | { nonce = null as string | null, kid = null as string | null } = {} 619 | ): Promise { 620 | const jwk = this.jwk 621 | let headerAlg: AcmeSignAlgo = 'RS256' 622 | let signerAlg = 'SHA-256' 623 | 624 | if (!jwk) { 625 | throw new Error('jwk is undefined') 626 | } 627 | /* https://datatracker.ietf.org/doc/html/rfc7518#section-3.1 */ 628 | if ('crv' in jwk && jwk.crv && jwk.kty === 'EC') { 629 | headerAlg = 'ES256' 630 | if (jwk.crv === 'P-384') { 631 | headerAlg = 'ES384' 632 | signerAlg = 'SHA-384' 633 | } else if (jwk.crv === 'P-521') { 634 | headerAlg = 'ES512' 635 | signerAlg = 'SHA-512' 636 | } 637 | } 638 | 639 | /* Prepare body and sign it */ 640 | const result = this.prepareSignedBody(headerAlg, url, payload, jwk, { 641 | nonce, 642 | kid, 643 | }) 644 | 645 | this.log.debug('Prepared signed payload', result) 646 | 647 | let sign 648 | if (jwk.kty === 'EC') { 649 | const hash = await crypto.subtle.digest( 650 | signerAlg as HashVariants, 651 | `${result.protected}.${result.payload}` 652 | ) 653 | sign = await crypto.subtle.sign( 654 | { 655 | name: 'ECDSA', 656 | hash: hash.toString() as HashVariants, 657 | }, 658 | this.accountKey, 659 | hash 660 | ) 661 | } else { 662 | sign = await crypto.subtle.sign( 663 | { name: 'RSASSA-PKCS1-v1_5' }, 664 | this.accountKey, 665 | `${result.protected}.${result.payload}` 666 | ) 667 | } 668 | 669 | result.signature = Buffer.from(sign).toString('base64url') 670 | return result 671 | } 672 | 673 | /** 674 | * Returns the account URL associated with the current client instance. 675 | * 676 | * @private 677 | * @returns {string} the account URL 678 | * @throws {Error} If no account URL has been set yet 679 | */ 680 | getAccountUrl(): string { 681 | if (!this.accountUrl) { 682 | throw new Error('No account URL found, register account first') 683 | } 684 | return this.accountUrl 685 | } 686 | 687 | /** 688 | * Get Terms of Service URL if available 689 | * 690 | * https://tools.ietf.org/html/rfc8555#section-7.1.1 691 | * 692 | * @returns {Promise} ToS URL 693 | */ 694 | async getTermsOfServiceUrl(): Promise { 695 | return this.getMetaField('termsOfService') as Promise 696 | } 697 | 698 | /** 699 | * Create new account 700 | * 701 | * https://tools.ietf.org/html/rfc8555#section-7.3 702 | * 703 | * @param {object} data Request payload. 704 | * @param {boolean} data.termsOfServiceAgreed Whether the client agrees to the terms of service. 705 | * @param {[]string} data.contact An array of contact info, e.g. ['mailto:admin@example.com']. 706 | * @param {boolean} data.onlyReturnExisting Whether the server should only return an existing account, or create a new one if it does not exist. 707 | * @returns {Promise} HTTP response. 708 | */ 709 | async createAccount(data: AccountCreateRequest): Promise { 710 | const resp = await this.apiResourceRequest('newAccount', data, [200, 201], { 711 | includeJwsKid: false, 712 | includeExternalAccountBinding: data.onlyReturnExisting !== true, 713 | }) 714 | 715 | /* Set account URL */ 716 | if (resp.headers.get('location')) { 717 | this.accountUrl = resp.headers.get('location') 718 | } 719 | 720 | return resp 721 | } 722 | 723 | /** 724 | * Update account 725 | * 726 | * https://tools.ietf.org/html/rfc8555#section-7.3.2 727 | * 728 | * @param {object} data Request payload 729 | * @returns {Promise} HTTP response 730 | */ 731 | updateAccount(data: AccountUpdateRequest): Promise { 732 | return this.apiRequest(this.getAccountUrl(), data, [200, 202]) 733 | } 734 | 735 | /** 736 | * Update account key 737 | * 738 | * https://tools.ietf.org/html/rfc8555#section-7.3.5 739 | * 740 | * @param {ApiPayload} data Request payload 741 | * @returns {Promise} HTTP response 742 | */ 743 | updateAccountKey(data: ApiPayload): Promise { 744 | return this.apiResourceRequest('keyChange', data, [200]) 745 | } 746 | 747 | /** 748 | * Create new order 749 | * 750 | * https://tools.ietf.org/html/rfc8555#section-7.4 751 | * 752 | * @param {ApiPayload} data Request payload 753 | * @returns {Promise} HTTP response 754 | */ 755 | createOrder(data: ApiPayload): Promise { 756 | return this.apiResourceRequest('newOrder', data, [201]) 757 | } 758 | 759 | /** 760 | * Get order 761 | * 762 | * https://tools.ietf.org/html/rfc8555#section-7.4 763 | * 764 | * @param {string} url Order URL 765 | * @returns {Promise} HTTP response 766 | */ 767 | getOrder(url: string): Promise { 768 | return this.apiRequest(url, null, [200]) 769 | } 770 | 771 | /** 772 | * Finalize order 773 | * 774 | * https://tools.ietf.org/html/rfc8555#section-7.4 775 | * 776 | * @param {string} url Finalization URL 777 | * @param {ApiPayload} data Request payload 778 | * @returns {Promise} HTTP response 779 | */ 780 | finalizeOrder(url: string, data: ApiPayload): Promise { 781 | return this.apiRequest(url, data, [200]) 782 | } 783 | 784 | /** 785 | * Get identifier authorization 786 | * 787 | * https://tools.ietf.org/html/rfc8555#section-7.5 788 | * 789 | * @param {string} url Authorization URL 790 | * @returns {Promise} HTTP response 791 | */ 792 | getAuthorization(url: string): Promise { 793 | return this.apiRequest(url, null, [200]) 794 | } 795 | 796 | /** 797 | * Update identifier authorization 798 | * 799 | * https://tools.ietf.org/html/rfc8555#section-7.5.2 800 | * 801 | * @param {string} url Authorization URL 802 | * @param {object} data Request payload 803 | * @returns {Promise} HTTP response 804 | */ 805 | updateAuthorization( 806 | url: string, 807 | data: UpdateAuthorizationData 808 | ): Promise { 809 | return this.apiRequest(url, data, [200]) 810 | } 811 | 812 | /** 813 | * Completes a pending challenge with the ACME server by sending a response payload to the challenge URL. 814 | * 815 | * https://tools.ietf.org/html/rfc8555#section-7.5.1 816 | * 817 | * @param {string} url Challenge URL 818 | * @param {ApiPayload} data Request payload 819 | * @returns {Promise} HTTP response 820 | */ 821 | completeChallenge(url: string, data: ApiPayload): Promise { 822 | return this.apiRequest(url, data, [200]) 823 | } 824 | 825 | /** 826 | * Revoke certificate 827 | * 828 | * https://tools.ietf.org/html/rfc8555#section-7.6 829 | * 830 | * 831 | * @param {ApiPayload} data - An object containing the data needed for revocation: 832 | * @param {string} data.certificate - The certificate to be revoked. 833 | * @param {number} data.reason - An optional reason for revocation (default: 1). 834 | * See this https://datatracker.ietf.org/doc/html/rfc5280#section-5.3.1 835 | * @returns {Promise} HTTP response 836 | */ 837 | revokeCert(data: ApiPayload): Promise { 838 | return this.apiResourceRequest('revokeCert', data, [200]) 839 | } 840 | 841 | /** 842 | * Set the `verify` property to enable or disable verification of the HTTPS server certificate. 843 | * 844 | * @param {boolean} v - The value to set `verify` to. 845 | */ 846 | setVerify(v: boolean): void { 847 | this.verify = v 848 | } 849 | 850 | /** how verbose these logs will be */ 851 | get minLevel(): LogLevel { 852 | return this.log.minLevel 853 | } 854 | /** controls how verbose these logs will be */ 855 | set minLevel(v: LogLevel) { 856 | this.log.minLevel = v 857 | } 858 | } 859 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from './api' 2 | import { LogLevel, Logger } from './logger' 3 | import { 4 | formatResponseError, 5 | getPemBodyAsB64u, 6 | readX509ServerNames, 7 | retry, 8 | toPEM, 9 | uniqueDomains, 10 | } from './utils' 11 | import OGCrypto from 'crypto' 12 | 13 | export interface ClientExternalAccountBindingOptions { 14 | kid: string 15 | hmacKey: string 16 | } 17 | 18 | /* rfc 8555 */ 19 | /** 20 | * Account 21 | * 22 | * https://tools.ietf.org/html/rfc8555#section-7.1.2 23 | * https://tools.ietf.org/html/rfc8555#section-7.3 24 | * https://tools.ietf.org/html/rfc8555#section-7.3.2 25 | */ 26 | export interface Account { 27 | status: 'valid' | 'deactivated' | 'revoked' 28 | orders: string 29 | contact?: string[] 30 | termsOfServiceAgreed?: boolean 31 | externalAccountBinding?: ClientExternalAccountBindingOptions 32 | } 33 | 34 | export interface AccountCreateRequest { 35 | contact?: string[] 36 | termsOfServiceAgreed?: boolean 37 | onlyReturnExisting?: boolean 38 | externalAccountBinding?: ClientExternalAccountBindingOptions 39 | } 40 | 41 | export type AccountUpdateRequest = { 42 | status?: string 43 | contact?: string[] 44 | termsOfServiceAgreed?: boolean 45 | externalAccountBinding?: ClientExternalAccountBindingOptions 46 | onlyReturnExisting?: boolean 47 | } | null 48 | 49 | /** 50 | * Order 51 | * 52 | * https://tools.ietf.org/html/rfc8555#section-7.1.3 53 | * https://tools.ietf.org/html/rfc8555#section-7.4 54 | */ 55 | export interface Order { 56 | status: 'pending' | 'ready' | 'processing' | 'valid' | 'invalid' 57 | identifiers: Identifier[] 58 | authorizations: string[] 59 | finalize: string 60 | expires?: string 61 | notBefore?: string 62 | notAfter?: string 63 | error?: Record 64 | certificate?: string 65 | url?: string 66 | } 67 | 68 | export interface OrderCreateRequest { 69 | identifiers: Identifier[] 70 | notBefore?: string 71 | notAfter?: string 72 | externalAccountBinding?: ClientExternalAccountBindingOptions 73 | } 74 | 75 | /** 76 | * Authorization 77 | * 78 | * https://tools.ietf.org/html/rfc8555#section-7.1.4 79 | */ 80 | export interface Authorization { 81 | identifier: Identifier 82 | status: 83 | | 'pending' 84 | | 'valid' 85 | | 'invalid' 86 | | 'deactivated' 87 | | 'expired' 88 | | 'revoked' 89 | challenges: Challenge[] 90 | expires?: string 91 | wildcard?: boolean 92 | url?: string 93 | } 94 | 95 | export interface Identifier { 96 | type: string 97 | value: string 98 | } 99 | 100 | /** 101 | * Challenge 102 | * 103 | * https://tools.ietf.org/html/rfc8555#section-8 104 | * https://tools.ietf.org/html/rfc8555#section-8.3 105 | * https://tools.ietf.org/html/rfc8555#section-8.4 106 | */ 107 | export interface ChallengeAbstract { 108 | type: string 109 | url: string 110 | status: 'pending' | 'processing' | 'valid' | 'invalid' 111 | validated?: string 112 | error?: Record 113 | } 114 | 115 | export interface HttpChallenge extends ChallengeAbstract { 116 | type: 'http-01' 117 | token: string 118 | } 119 | 120 | export interface DnsChallenge extends ChallengeAbstract { 121 | type: 'dns-01' 122 | token: string 123 | } 124 | 125 | export interface TlsAlpnChallenge extends ChallengeAbstract { 126 | type: 'tls-alpn-01' 127 | token: string 128 | } 129 | 130 | export type Challenge = HttpChallenge | DnsChallenge | TlsAlpnChallenge 131 | 132 | /** 133 | * Certificate 134 | * 135 | * https://tools.ietf.org/html/rfc8555#section-7.6 136 | */ 137 | export enum CertificateRevocationReason { 138 | Unspecified = 0, 139 | KeyCompromise = 1, 140 | CACompromise = 2, 141 | AffiliationChanged = 3, 142 | Superseded = 4, 143 | CessationOfOperation = 5, 144 | CertificateHold = 6, 145 | RemoveFromCRL = 8, 146 | PrivilegeWithdrawn = 9, 147 | AACompromise = 10, 148 | } 149 | 150 | export interface CertificateRevocationRequest { 151 | reason?: CertificateRevocationReason 152 | } 153 | 154 | export interface ClientOptions { 155 | directoryUrl: string 156 | accountKey: CryptoKey 157 | accountUrl?: string 158 | externalAccountBinding?: ClientExternalAccountBindingOptions 159 | backoffAttempts?: number 160 | backoffMin?: number 161 | backoffMax?: number 162 | } 163 | 164 | const autoDefaultOpts = { 165 | challengePriority: ['http-01'], 166 | termsOfServiceAgreed: false, 167 | challengeCreateFn: async ( 168 | _authz: Authorization, 169 | _challenge: Challenge, 170 | _keyAuthorization: string 171 | ): Promise => { 172 | throw new Error('Missing challengeCreateFn()') 173 | }, 174 | challengeRemoveFn: async ( 175 | _authz: Authorization, 176 | _challenge: Challenge, 177 | _keyAuthorization: string 178 | ): Promise => { 179 | throw new Error('Missing challengeRemoveFn()') 180 | }, 181 | } 182 | 183 | export interface ClientAutoOptions extends Partial { 184 | csr: Buffer | string 185 | email: string 186 | preferredChain?: string 187 | } 188 | 189 | /** 190 | * ACME states 191 | * 192 | * @private 193 | */ 194 | 195 | const validStates = ['ready', 'valid'] 196 | const pendingStates = ['pending', 'processing'] 197 | const invalidStates = ['invalid'] 198 | 199 | /** 200 | * Default options 201 | * 202 | * @private 203 | */ 204 | const defaultOpts = { 205 | directoryUrl: undefined, 206 | accountKey: undefined, 207 | accountUrl: null, 208 | externalAccountBinding: {}, 209 | backoffAttempts: 10, 210 | backoffMin: 3000, 211 | backoffMax: 30000, 212 | } 213 | 214 | /** 215 | * AcmeClient 216 | * 217 | * @class 218 | * @param {object} opts 219 | * @param {string} opts.directoryUrl ACME directory URL 220 | * @param {buffer|string} opts.accountKey PEM encoded account private key 221 | * @param {string} [opts.accountUrl] Account URL, default: `null` 222 | * @param {object} [opts.externalAccountBinding] 223 | * @param {string} [opts.externalAccountBinding.kid] External account binding KID 224 | * @param {string} [opts.externalAccountBinding.hmacKey] External account binding HMAC key 225 | * @param {number} [opts.backoffAttempts] Maximum number of backoff attempts, default: `10` 226 | * @param {number} [opts.backoffMin] Minimum backoff attempt delay in milliseconds, default: `5000` 227 | * @param {number} [opts.backoffMax] Maximum backoff attempt delay in milliseconds, default: `30000` 228 | * 229 | * @example Create ACME client instance 230 | * ```js 231 | * const client = new acme.Client({ 232 | * directoryUrl: acme.directory.letsencrypt.staging, 233 | * accountKey: 'Private key goes here' 234 | * }); 235 | * ``` 236 | * 237 | * @example Create ACME client instance 238 | * ```js 239 | * const client = new acme.Client({ 240 | * directoryUrl: acme.directory.letsencrypt.staging, 241 | * accountKey: <'Private key goes here'>, 242 | * accountUrl: 'Optional account URL goes here', 243 | * backoffAttempts: 10, 244 | * backoffMin: 5000, 245 | * backoffMax: 30000 246 | * }); 247 | * ``` 248 | * 249 | * @example Create ACME client with external account binding 250 | * ```js 251 | * const client = new acme.Client({ 252 | * directoryUrl: 'https://acme-provider.example.com/directory-url', 253 | * accountKey: 'Private key goes here', 254 | * externalAccountBinding: { 255 | * kid: 'YOUR-EAB-KID', 256 | * hmacKey: 'YOUR-EAB-HMAC-KEY' 257 | * } 258 | * }); 259 | * ``` 260 | */ 261 | export class AcmeClient { 262 | opts: ClientOptions 263 | backoffOpts: { 264 | attempts: number | undefined 265 | min: number | undefined 266 | max: number | undefined 267 | } 268 | api: HttpClient 269 | private log: Logger 270 | 271 | constructor(opts: ClientOptions) { 272 | // if (!Buffer.isBuffer(opts.accountKey)) { 273 | // opts.accountKey = Buffer.from(opts.accountKey); 274 | // } 275 | 276 | this.opts = Object.assign({}, defaultOpts, opts) 277 | 278 | this.backoffOpts = { 279 | attempts: this.opts.backoffAttempts, 280 | min: this.opts.backoffMin, 281 | max: this.opts.backoffMax, 282 | } 283 | 284 | this.log = new Logger('client', LogLevel.Info) 285 | 286 | // FIXME accountKey - is a CryptoKey object not a PEM/string/Object... 287 | this.api = new HttpClient( 288 | this.opts.directoryUrl, 289 | this.opts.accountKey, 290 | this.opts.accountUrl 291 | ) 292 | } 293 | 294 | /** 295 | * Get Terms of Service URL if available 296 | * 297 | * @returns {Promise} ToS URL 298 | * 299 | * @example Get Terms of Service URL 300 | * ```js 301 | * const termsOfService = client.getTermsOfServiceUrl(); 302 | * 303 | * if (!termsOfService) { 304 | * // CA did not provide Terms of Service 305 | * } 306 | * ``` 307 | */ 308 | async getTermsOfServiceUrl(): Promise { 309 | return this.api.getTermsOfServiceUrl() 310 | } 311 | 312 | /** 313 | * Get current account URL 314 | * 315 | * @returns {string} Account URL 316 | * @throws {Error} No account URL found 317 | * 318 | * @example Get current account URL 319 | * ```js 320 | * try { 321 | * const accountUrl = client.getAccountUrl(); 322 | * } 323 | * catch (e) { 324 | * // No account URL exists, need to create account first 325 | * } 326 | * ``` 327 | */ 328 | getAccountUrl(): string { 329 | return this.api.getAccountUrl() 330 | } 331 | 332 | /** 333 | * Create a new account 334 | * 335 | * https://tools.ietf.org/html/rfc8555#section-7.3 336 | * 337 | * @param {object} [data] Request data 338 | * @returns {Promise} Account 339 | * 340 | * @example Create a new account 341 | * ```js 342 | * const account = await client.createAccount({ 343 | * termsOfServiceAgreed: true 344 | * }); 345 | * ``` 346 | * 347 | * @example Create a new account with contact info 348 | * ```js 349 | * const account = await client.createAccount({ 350 | * termsOfServiceAgreed: true, 351 | * contact: ['mailto:test@example.com'] 352 | * }); 353 | * ``` 354 | */ 355 | async createAccount( 356 | data: AccountCreateRequest = { 357 | termsOfServiceAgreed: false, 358 | } 359 | ): Promise> { 360 | try { 361 | this.getAccountUrl() 362 | 363 | /* Account URL exists */ 364 | this.log.info('Account URL exists, updating it...') 365 | return await this.updateAccount(data) 366 | } catch (e) { 367 | const resp = await this.api.createAccount(data) 368 | 369 | /* HTTP 200: Account exists */ 370 | if (resp.status === 200) { 371 | this.log.info('Account already exists (HTTP 200), updating it...') 372 | return await this.updateAccount(data) 373 | } 374 | return (await resp.json()) as Promise> 375 | } 376 | } 377 | 378 | /** 379 | * Update existing account 380 | * 381 | * https://tools.ietf.org/html/rfc8555#section-7.3.2 382 | * 383 | * @param {object} [data] Request data 384 | * @returns {Promise} Account 385 | * 386 | * @example Update existing account 387 | * ```js 388 | * const account = await client.updateAccount({ 389 | * contact: ['mailto:foo@example.com'] 390 | * }); 391 | * ``` 392 | */ 393 | async updateAccount( 394 | data: AccountUpdateRequest = {} 395 | ): Promise> { 396 | try { 397 | this.api.getAccountUrl() 398 | } catch (e) { 399 | return this.createAccount(data || undefined) 400 | } 401 | 402 | /* Remove data only applicable to createAccount() */ 403 | if (data && 'onlyReturnExisting' in data) { 404 | delete data.onlyReturnExisting 405 | } 406 | 407 | /* POST-as-GET */ 408 | if (data && Object.keys(data).length === 0) { 409 | data = null 410 | } 411 | 412 | const resp = await this.api.updateAccount(data) 413 | return (await resp.json()) as Promise> 414 | } 415 | 416 | /** 417 | * Update account private key 418 | * 419 | * https://tools.ietf.org/html/rfc8555#section-7.3.5 420 | * 421 | * @param {CryptoKey} newAccountKey New account private key 422 | * @param {object} [data] Additional request data 423 | * @returns {Promise} Account 424 | * 425 | * @example Update account private key 426 | * ```js 427 | * const newAccountKey = 'New private key goes here'; 428 | * const result = await client.updateAccountKey(newAccountKey); 429 | * ``` 430 | */ 431 | async updateAccountKey( 432 | newAccountKey: CryptoKey, 433 | data: Record = {} 434 | ): Promise> { 435 | const accountUrl = this.api.getAccountUrl() 436 | 437 | /* Create new HTTP and API clients using new key */ 438 | const newHttpClient = new HttpClient( 439 | this.opts.directoryUrl, 440 | newAccountKey, 441 | accountUrl 442 | ) 443 | 444 | /* Get old JWK */ 445 | data.account = accountUrl 446 | data.oldKey = this.api.getJwk() 447 | 448 | /* Get signed request body from new client */ 449 | const url = await newHttpClient.getResourceUrl('keyChange') 450 | const body = await newHttpClient.createSignedBody(url, data) 451 | 452 | /* Change key using old client */ 453 | const resp = await this.api.updateAccountKey(body) 454 | 455 | /* Replace existing HTTP and API client */ 456 | this.api = newHttpClient 457 | 458 | // FIXME 459 | return (await resp.json()) as Record 460 | } 461 | 462 | /** 463 | * Create a new order 464 | * 465 | * https://tools.ietf.org/html/rfc8555#section-7.4 466 | * 467 | * @param {object} data Request data 468 | * @returns {Promise} Order 469 | * 470 | * @example Create a new order 471 | * ```js 472 | * const order = await client.createOrder({ 473 | * identifiers: [ 474 | * { type: 'dns', value: 'example.com' }, 475 | * { type: 'dns', value: 'test.example.com' } 476 | * ] 477 | * }); 478 | * ``` 479 | */ 480 | async createOrder(data: OrderCreateRequest): Promise { 481 | const resp = await this.api.createOrder(data) 482 | 483 | if (!resp.headers.get('location')) { 484 | throw new Error('Creating a new order did not return an order link') 485 | } 486 | 487 | // FIXME 488 | /* Add URL to response */ 489 | const respData = (await resp.json()) as Order 490 | respData.url = resp.headers.get('location') 491 | return respData 492 | } 493 | 494 | /** 495 | * Refresh order object from CA 496 | * 497 | * https://tools.ietf.org/html/rfc8555#section-7.4 498 | * 499 | * @param {object} order Order object 500 | * @returns {Promise} Order 501 | * 502 | * @example 503 | * ```js 504 | * const order = { ... }; // Previously created order object 505 | * const result = await client.getOrder(order); 506 | * ``` 507 | */ 508 | async getOrder(order: Order): Promise> { 509 | if (!order.url) { 510 | throw new Error('Unable to get order, URL not found') 511 | } 512 | 513 | const resp = await this.api.getOrder(order.url) 514 | 515 | /* Add URL to response */ 516 | const respData = (await resp.json()) as Record 517 | respData.url = order.url 518 | return respData 519 | } 520 | 521 | /** 522 | * Finalize order 523 | * 524 | * https://tools.ietf.org/html/rfc8555#section-7.4 525 | * 526 | * @param {object} order Order object 527 | * @param {buffer|string} csr PEM encoded Certificate Signing Request 528 | * @returns {Promise} Order 529 | * 530 | * @example Finalize order 531 | * ```js 532 | * const order = { ... }; // Previously created order object 533 | * const csr = { ... }; // Previously created Certificate Signing Request 534 | * const result = await client.finalizeOrder(order, csr); 535 | * ``` 536 | */ 537 | async finalizeOrder( 538 | order: Order, 539 | csr: Buffer | string 540 | ): Promise> { 541 | if (!order.finalize) { 542 | throw new Error('Unable to finalize order, URL not found') 543 | } 544 | 545 | if (!Buffer.isBuffer(csr)) { 546 | csr = Buffer.from(csr) 547 | } 548 | 549 | const data = { csr: getPemBodyAsB64u(csr) } 550 | let resp 551 | try { 552 | resp = await this.api.finalizeOrder(order.finalize, data) 553 | } catch (e) { 554 | this.log.warn('finalize order failed:', e) 555 | throw e 556 | } 557 | /* Add URL to response */ 558 | const respData = (await resp.json()) as Record 559 | respData.url = order.url 560 | return respData 561 | } 562 | 563 | /** 564 | * Get identifier authorizations from order 565 | * 566 | * https://tools.ietf.org/html/rfc8555#section-7.5 567 | * 568 | * @param {object} order Order 569 | * @returns {Promise} Authorizations 570 | * 571 | * @example Get identifier authorizations 572 | * ```js 573 | * const order = { ... }; // Previously created order object 574 | * const authorizations = await client.getAuthorizations(order); 575 | * 576 | * authorizations.forEach((authz) => { 577 | * const { challenges } = authz; 578 | * }); 579 | * ``` 580 | */ 581 | async getAuthorizations(order: Order): Promise { 582 | return Promise.all( 583 | (order.authorizations || []).map(async (url) => { 584 | const resp = await this.api.getAuthorization(url) 585 | const respData = (await resp.json()) as Authorization 586 | /* Add URL to response */ 587 | respData.url = url 588 | return respData 589 | }) 590 | ) 591 | } 592 | 593 | /** 594 | * Deactivate identifier authorization 595 | * 596 | * https://tools.ietf.org/html/rfc8555#section-7.5.2 597 | * 598 | * @param {object} authz Identifier authorization 599 | * @returns {Promise} Authorization 600 | * 601 | * @example Deactivate identifier authorization 602 | * ```js 603 | * const authz = { ... }; // Identifier authorization resolved from previously created order 604 | * const result = await client.deactivateAuthorization(authz); 605 | * ``` 606 | */ 607 | async deactivateAuthorization( 608 | authz: Authorization 609 | ): Promise> { 610 | if (!authz.url) { 611 | throw new Error( 612 | 'Unable to deactivate identifier authorization, URL not found' 613 | ) 614 | } 615 | 616 | const data = { 617 | status: 'deactivated', 618 | } 619 | 620 | const resp = await this.api.updateAuthorization(authz.url as string, data) 621 | 622 | /* Add URL to response */ 623 | const respData = (await resp.json()) as Record 624 | respData.url = authz.url 625 | return respData 626 | } 627 | 628 | /** 629 | * Get key authorization for ACME challenge 630 | * 631 | * https://tools.ietf.org/html/rfc8555#section-8.1 632 | * 633 | * @param {object} challenge Challenge object returned by API 634 | * @returns {Promise} Key authorization 635 | * 636 | * @example Get challenge key authorization 637 | * ```js 638 | * const challenge = { ... }; // Challenge from previously resolved identifier authorization 639 | * const key = await client.getChallengeKeyAuthorization(challenge); 640 | * 641 | * // Write key somewhere to satisfy challenge 642 | * ``` 643 | */ 644 | async getChallengeKeyAuthorization(challenge: Challenge): Promise { 645 | const jwk = await this.api.getJwk() 646 | 647 | const keysum = OGCrypto.createHash('sha256').update(JSON.stringify(jwk)) 648 | const thumbprint = keysum.digest('base64url') 649 | const result = `${challenge.token}.${thumbprint}` 650 | 651 | /** 652 | * https://tools.ietf.org/html/rfc8555#section-8.3 653 | */ 654 | if (challenge.type === 'http-01') { 655 | return result 656 | } 657 | 658 | /** 659 | * https://tools.ietf.org/html/rfc8555#section-8.4 660 | * https://tools.ietf.org/html/draft-ietf-acme-tls-alpn-01 661 | */ 662 | if (challenge.type === 'dns-01' || challenge.type === 'tls-alpn-01') { 663 | throw new Error(`Unsupported challenge type: ${challenge.type}`) 664 | } 665 | 666 | throw new Error( 667 | `Unable to produce key authorization, unknown challenge type: ${challenge}` 668 | ) 669 | } 670 | 671 | /** 672 | * Notify CA that challenge has been completed 673 | * 674 | * https://tools.ietf.org/html/rfc8555#section-7.5.1 675 | * 676 | * @param {object} challenge Challenge object returned by API 677 | * @returns {Promise} Challenge 678 | * 679 | * @example Notify CA that challenge has been completed 680 | * ```js 681 | * const challenge = { ... }; // Satisfied challenge 682 | * const result = await client.completeChallenge(challenge); 683 | * ``` 684 | */ 685 | async completeChallenge( 686 | challenge: Challenge 687 | ): Promise> { 688 | const resp = await this.api.completeChallenge(challenge.url as string, {}) 689 | return (await resp.json()) as Record 690 | } 691 | 692 | /** 693 | * Wait for ACME provider to verify status on a order, authorization or challenge 694 | * 695 | * https://tools.ietf.org/html/rfc8555#section-7.5.1 696 | * 697 | * @param {object} item An order, authorization or challenge object 698 | * @returns {Promise} Valid order, authorization or challenge 699 | * 700 | * @example Wait for valid challenge status 701 | * ```js 702 | * const challenge = { ... }; 703 | * await client.waitForValidStatus(challenge); 704 | * ``` 705 | * 706 | * @example Wait for valid authorization status 707 | * ```js 708 | * const authz = { ... }; 709 | * await client.waitForValidStatus(authz); 710 | * ``` 711 | * 712 | * @example Wait for valid order status 713 | * ```js 714 | * const order = { ... }; 715 | * await client.waitForValidStatus(order); 716 | * ``` 717 | */ 718 | async waitForValidStatus( 719 | item: Record | Challenge 720 | ): Promise> { 721 | if (!item.url) { 722 | throw new Error('Unable to verify status of item, URL not found') 723 | } 724 | 725 | const verifyFn = async (abort: () => void) => { 726 | const resp = await this.api.apiRequest(item.url as string, null, [200]) 727 | 728 | /* Verify status */ 729 | const respData = (await resp.json()) as Record 730 | this.log.info('Item has status:', respData.status) 731 | 732 | if (invalidStates.includes(respData.status)) { 733 | abort() 734 | throw new Error(formatResponseError(respData)) 735 | } else if (pendingStates.includes(respData.status)) { 736 | throw new Error('Operation is pending or processing') 737 | } else if (validStates.includes(respData.status)) { 738 | return respData 739 | } 740 | 741 | throw new Error(`Unexpected item status: ${respData.status}`) 742 | } 743 | 744 | this.log.info('Waiting for valid status from', item.url, this.backoffOpts) 745 | return retry(verifyFn, this.backoffOpts) as Promise> 746 | } 747 | 748 | /** 749 | * Get certificate from ACME order 750 | * 751 | * https://tools.ietf.org/html/rfc8555#section-7.4.2 752 | * 753 | * @param {object} order Order object 754 | * @param {string} [preferredChain] Indicate which certificate chain is preferred if a CA offers multiple, by exact issuer common name, default: `null` 755 | * @returns {Promise} Certificate 756 | * 757 | * @example Get certificate 758 | * ```js 759 | * const order = { ... }; // Previously created order 760 | * const certificate = await client.getCertificate(order); 761 | * ``` 762 | * 763 | * @example Get certificate with preferred chain 764 | * ```js 765 | * const order = { ... }; // Previously created order 766 | * const certificate = await client.getCertificate(order, 'DST Root CA X3'); 767 | * ``` 768 | */ 769 | async getCertificate( 770 | order: Record, 771 | _preferredChain: string | null = null // TODO delete? 772 | ): Promise { 773 | if (!validStates.includes(order.status as string)) { 774 | order = await this.waitForValidStatus(order) 775 | } 776 | 777 | if (!order.certificate) { 778 | throw new Error('Unable to download certificate, URL not found') 779 | } 780 | 781 | const resp = await this.api.apiRequest(order.certificate as string, null, [ 782 | 200, 783 | ]) 784 | 785 | /* Handle alternate certificate chains */ 786 | // TODO -- SHOULD WE DELETE THIS? OR IMPLEMENT utils.* 787 | //if (preferredChain && resp.headers.link) { 788 | // const alternateLinks = util.parseLinkHeader(resp.headers.link) 789 | // const alternates = await Promise.all( 790 | // alternateLinks.map(async (link: string) => 791 | // this.api.apiRequest(link, null, [200]) 792 | // ) 793 | // ) 794 | // const certificates = [resp].concat(alternates).map((c) => c.data) 795 | 796 | // return util.findCertificateChainForIssuer(certificates, preferredChain) 797 | //} 798 | 799 | /* Return default certificate chain */ 800 | return await resp.text() 801 | } 802 | 803 | /** 804 | * Revoke certificate 805 | * 806 | * https://tools.ietf.org/html/rfc8555#section-7.6 807 | * 808 | * @param {buffer|string} cert PEM encoded certificate 809 | * @param {object} [data] Additional request data 810 | * @returns {Promise} 811 | * 812 | * @example Revoke certificate 813 | * ```js 814 | * const certificate = { ... }; // Previously created certificate 815 | * const result = await client.revokeCertificate(certificate); 816 | * ``` 817 | * 818 | * @example Revoke certificate with reason 819 | * ```js 820 | * const certificate = { ... }; // Previously created certificate 821 | * const result = await client.revokeCertificate(certificate, { 822 | * reason: 4 823 | * }); 824 | * ``` 825 | */ 826 | async revokeCertificate( 827 | cert: Buffer | string, 828 | data: Record = {} 829 | ): Promise> { 830 | data.certificate = getPemBodyAsB64u(cert) 831 | const resp = await this.api.revokeCert(data) 832 | return (await resp.json()) as Record 833 | } 834 | 835 | /** 836 | * Auto mode 837 | * 838 | * @param {object} opts 839 | * @param {buffer|string} opts.csr Certificate Signing Request 840 | * @param {function} opts.challengeCreateFn Function returning Promise triggered before completing ACME challenge 841 | * @param {function} opts.challengeRemoveFn Function returning Promise triggered after completing ACME challenge 842 | * @param {string} [opts.email] Account email address 843 | * @param {boolean} [opts.termsOfServiceAgreed] Agree to Terms of Service, default: `false` 844 | * @param {string[]} [opts.challengePriority] Array defining challenge type priority, default: `['http-01', 'dns-01']` 845 | * @param {string} [opts.preferredChain] Indicate which certificate chain is preferred if a CA offers multiple, by exact issuer common name, default: `null` 846 | * @returns {Promise} Certificate 847 | * 848 | * @example Order a certificate using auto mode 849 | * ```js 850 | * const [certificateKey, certificateRequest] = await acme.crypto.createCsr({ 851 | * commonName: 'test.example.com' 852 | * }); 853 | * 854 | * const certificate = await client.auto({ 855 | * csr: certificateRequest, 856 | * email: 'test@example.com', 857 | * termsOfServiceAgreed: true, 858 | * challengeCreateFn: async (authz, challenge, keyAuthorization) => { 859 | * // Satisfy challenge here 860 | * }, 861 | * challengeRemoveFn: async (authz, challenge, keyAuthorization) => { 862 | * // Clean up challenge here 863 | * } 864 | * }); 865 | * ``` 866 | * 867 | * @example Order a certificate using auto mode with preferred chain 868 | * ```js 869 | * const [certificateKey, certificateRequest] = await acme.crypto.createCsr({ 870 | * commonName: 'test.example.com' 871 | * }); 872 | * 873 | * const certificate = await client.auto({ 874 | * csr: certificateRequest, 875 | * email: 'test@example.com', 876 | * termsOfServiceAgreed: true, 877 | * preferredChain: 'DST Root CA X3', 878 | * challengeCreateFn: async () => {}, 879 | * challengeRemoveFn: async () => {} 880 | * }); 881 | * ``` 882 | */ 883 | auto(opts: ClientAutoOptions): Promise { 884 | return auto(this, opts) 885 | } 886 | 887 | /** how verbose these logs will be */ 888 | get minLevel(): LogLevel { 889 | return this.log.minLevel 890 | } 891 | 892 | /** controls how verbose these logs will be. Does not affect the level of {@link AcmeClient.api}. */ 893 | set minLevel(v: LogLevel) { 894 | this.log.minLevel = v 895 | } 896 | } 897 | 898 | /** 899 | * ACME client auto mode 900 | * 901 | * @param {AcmeClient} client ACME client 902 | * @param {ClientAutoOptions} userOpts Options 903 | * @returns {Promise} Certificate 904 | */ 905 | async function auto( 906 | client: AcmeClient, 907 | userOpts: ClientAutoOptions 908 | ): Promise { 909 | const opts = { ...autoDefaultOpts, ...userOpts } 910 | const log = new Logger('auto', client.minLevel) 911 | const accountPayload: Record = { 912 | termsOfServiceAgreed: opts.termsOfServiceAgreed, 913 | } 914 | 915 | if (!Buffer.isBuffer(opts.csr) && opts.csr) { 916 | opts.csr = Buffer.from(opts.csr) 917 | } 918 | 919 | if (opts.email) { 920 | accountPayload.contact = [`mailto:${opts.email}`] 921 | } 922 | 923 | /** 924 | * Register account 925 | */ 926 | log.info('Checking account') 927 | 928 | try { 929 | client.getAccountUrl() 930 | log.info('Account URL already exists, skipping account registration') 931 | } catch (e) { 932 | log.info('Registering account') 933 | await client.createAccount(accountPayload) 934 | } 935 | 936 | /** 937 | * Parse domains from CSR 938 | */ 939 | log.info('Parsing domains from Certificate Signing Request') 940 | 941 | if (opts.csr === null) { 942 | throw new Error('csr is required') 943 | } 944 | 945 | const csrDomains = readX509ServerNames(toPEM(opts.csr, 'CERTIFICATE REQUEST')) 946 | const domains = uniqueDomains(csrDomains) 947 | 948 | log.info( 949 | `Resolved ${domains.length} unique domains (${domains.join( 950 | ', ' 951 | )}) from parsing the Certificate Signing Request` 952 | ) 953 | 954 | /** 955 | * Place order 956 | */ 957 | const orderPayload = { 958 | identifiers: domains.map((d) => ({ type: 'dns', value: d })), 959 | } 960 | const order = await client.createOrder(orderPayload) 961 | const authorizations = await client.getAuthorizations(order) 962 | log.info(`Placed certificate order successfully`) 963 | 964 | /** 965 | * Resolve and satisfy challenges 966 | */ 967 | log.info('Resolving and satisfying authorization challenges') 968 | 969 | const challengePromises = authorizations.map(async (authz: Authorization) => { 970 | const d = authz.identifier?.value 971 | let challengeCompleted = false 972 | 973 | /* Skip authz that already has valid status */ 974 | if (authz.status === 'valid') { 975 | log.info( 976 | `[${d}] Authorization already has valid status, no need to complete challenges` 977 | ) 978 | return 979 | } 980 | 981 | try { 982 | /* Select challenge based on priority */ 983 | const challenge = authz.challenges 984 | ?.sort((a: Challenge, b: Challenge) => { 985 | const aidx = opts.challengePriority.indexOf(a.type) 986 | const bidx = opts.challengePriority.indexOf(b.type) 987 | 988 | if (aidx === -1) return 1 989 | if (bidx === -1) return -1 990 | return aidx - bidx 991 | }) 992 | .slice(0, 1)[0] 993 | 994 | if (!challenge) { 995 | throw new Error( 996 | `Unable to select challenge for ${d}, no challenge found` 997 | ) 998 | } 999 | 1000 | log.info( 1001 | `[${d}] Found ${authz.challenges.length} challenges, selected type: ${challenge.type}` 1002 | ) 1003 | 1004 | /* Trigger challengeCreateFn() */ 1005 | const keyAuthorization = await client.getChallengeKeyAuthorization( 1006 | challenge 1007 | ) 1008 | 1009 | try { 1010 | await opts.challengeCreateFn(authz, challenge, keyAuthorization) 1011 | 1012 | /* Complete challenge and wait for valid status */ 1013 | log.info( 1014 | `[${d}] Completing challenge with ACME provider and waiting for valid status` 1015 | ) 1016 | await client.completeChallenge(challenge) 1017 | challengeCompleted = true 1018 | 1019 | await client.waitForValidStatus(challenge) 1020 | } finally { 1021 | /* Trigger challengeRemoveFn(), suppress errors */ 1022 | try { 1023 | await opts.challengeRemoveFn(authz, challenge, keyAuthorization) 1024 | } catch (e) { 1025 | log.info( 1026 | `[${d}] challengeRemoveFn threw error: ${(e as Error).message}` 1027 | ) 1028 | } 1029 | } 1030 | } catch (e: unknown) { 1031 | /* Deactivate pending authz when unable to complete challenge */ 1032 | if (!challengeCompleted) { 1033 | log.info(`[${d}] Unable to complete challenge: ${(e as Error).message}`) 1034 | 1035 | try { 1036 | await client.deactivateAuthorization(authz) 1037 | } catch (f: unknown) { 1038 | /* Suppress deactivateAuthorization() errors */ 1039 | log.info( 1040 | `[${d}] Authorization deactivation threw error: ${ 1041 | (f as Error).message 1042 | }` 1043 | ) 1044 | } 1045 | } 1046 | 1047 | throw e 1048 | } 1049 | }) 1050 | 1051 | log.info('Waiting for challenge valid status') 1052 | await Promise.all(challengePromises) 1053 | 1054 | if (!opts.csr) { 1055 | throw new Error('options is missing required csr') 1056 | } 1057 | 1058 | /** 1059 | * Finalize order and download certificate 1060 | */ 1061 | log.info('Finalize order and download certificate') 1062 | const finalized = await client.finalizeOrder(order, opts.csr) 1063 | const certData = await client.getCertificate(finalized, opts.preferredChain) 1064 | return certData 1065 | } 1066 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import x509 from './x509.js' 2 | import * as pkijs from 'pkijs' 3 | import * as asn1js from 'asn1js' 4 | import fs from 'fs' 5 | import querystring from 'querystring' 6 | import { ClientExternalAccountBindingOptions } from './client' 7 | import { Logger } from './logger' 8 | 9 | const log = new Logger('utils') 10 | 11 | // workaround for PKI.JS to work 12 | globalThis.unescape = querystring.unescape 13 | 14 | // make PKI.JS to work with webcrypto 15 | pkijs.setEngine( 16 | 'webcrypto', 17 | new pkijs.CryptoEngine({ name: 'webcrypto', crypto: crypto }) 18 | ) 19 | 20 | export interface RsaPublicJwk { 21 | e: string 22 | kty: string 23 | n: string 24 | externalAccountBinding?: ClientExternalAccountBindingOptions 25 | } 26 | 27 | export interface EcdsaPublicJwk { 28 | crv: string 29 | kty: string 30 | x: string 31 | y: string 32 | externalAccountBinding?: ClientExternalAccountBindingOptions 33 | } 34 | 35 | const ACCOUNT_KEY_ALG_GENERATE: RsaHashedKeyGenParams = { 36 | name: 'RSASSA-PKCS1-v1_5', 37 | hash: 'SHA-256', 38 | publicExponent: new Uint8Array([1, 0, 1]), 39 | modulusLength: 2048, 40 | } 41 | 42 | const ACCOUNT_KEY_ALG_IMPORT: RsaHashedImportParams = { 43 | name: 'RSASSA-PKCS1-v1_5', 44 | hash: 'SHA-256', 45 | } 46 | 47 | export const KEY_SUFFIX = '.key' 48 | export const CERTIFICATE_SUFFIX = '.crt' 49 | export const CERTIFICATE_REQ_SUFFIX = '.csr' 50 | 51 | /** 52 | * Generates RSA private and public key pair 53 | * @returns {CryptoKeyPair} a private and public key pair 54 | */ 55 | export async function generateKey(): Promise { 56 | const keys = await crypto.subtle.generateKey(ACCOUNT_KEY_ALG_GENERATE, true, [ 57 | 'sign', 58 | 'verify', 59 | ]) 60 | return keys 61 | } 62 | 63 | /** 64 | * Reads the account key from the specified file path, or creates a new one if it does not exist. 65 | * @param {string} [path] - The path to the account key file. If not specified, the default location will be used. 66 | * @returns {Promise} - The account key as a CryptoKey object. 67 | * @throws {Error} - If the account key cannot be read or generated. 68 | */ 69 | export async function readOrCreateAccountKey(path: string): Promise { 70 | try { 71 | const accountKeyJWK = fs.readFileSync(path, 'utf8') 72 | log.info('Using account key from', path) 73 | return await crypto.subtle.importKey( 74 | 'jwk', 75 | JSON.parse(accountKeyJWK), 76 | ACCOUNT_KEY_ALG_IMPORT, 77 | true, 78 | ['sign'] 79 | ) 80 | } catch (e) { 81 | // TODO: separate file not found, issues with importKey 82 | log.warn(`error ${e} while reading a private key from ${path}`) 83 | 84 | /* Generate a new RSA key pair for ACME account */ 85 | const keys = (await generateKey()) as Required 86 | const jwkFormated = await crypto.subtle.exportKey('jwk', keys.privateKey) 87 | fs.writeFileSync(path, JSON.stringify(jwkFormated)) 88 | log.info('Generated a new account key and saved it to', path) 89 | return keys.privateKey 90 | } 91 | } 92 | 93 | interface JWK { 94 | crv: unknown 95 | x: unknown 96 | e: unknown 97 | y: unknown 98 | kty: unknown 99 | n: unknown 100 | } 101 | /** 102 | * Gets the public JWK from a given private key. 103 | * @param {CryptoKey} privateKey - The private key to extract the public JWK from. 104 | * @returns {Promise} The public JWK. 105 | * @throws {Error} Throws an error if the privateKey parameter is not provided or invalid. 106 | */ 107 | export async function getPublicJwk( 108 | privateKey: CryptoKey 109 | ): Promise { 110 | if (!privateKey) { 111 | const errMsg = 'Invalid or missing private key' 112 | log.error(errMsg) 113 | throw new Error(errMsg) 114 | } 115 | 116 | // eslint-disable-next-line @typescript-eslint/ban-types 117 | const jwk = (await crypto.subtle.exportKey('jwk', privateKey)) as JWK 118 | 119 | if (jwk.crv && jwk.kty === 'EC') { 120 | const { crv, x, y, kty } = jwk 121 | return { 122 | crv, 123 | kty, 124 | x, 125 | y, 126 | } as EcdsaPublicJwk 127 | } else { 128 | return { 129 | e: jwk.e, 130 | kty: jwk.kty, 131 | n: jwk.n, 132 | } as RsaPublicJwk 133 | } 134 | } 135 | 136 | /** 137 | * Add line break every 64th character 138 | * @param pemString {string} 139 | * @returns {string} 140 | */ 141 | export function formatPEM(pemString: string): string { 142 | return pemString.replace(/(.{64})/g, '$1\n') 143 | } 144 | 145 | export type PemTag = 146 | | 'PRIVATE KEY' 147 | | 'PUBLIC KEY' 148 | | 'CERTIFICATE' 149 | | 'CERTIFICATE REQUEST' 150 | 151 | /** 152 | * Convert ArrayBufferView | ArrayBuffer to PEM string 153 | * @param buffer The ArrayBufferView | ArrayBuffer to convert to PEM 154 | * @param tag The tag to use for the PEM header and footer 155 | * @returns The converted PEM string 156 | */ 157 | export function toPEM( 158 | buffer: string | Buffer | ArrayBufferView | ArrayBuffer, 159 | tag: PemTag 160 | ): string { 161 | /** 162 | * Convert the ArrayBufferView or ArrayBuffer to base64 and format it 163 | * as a PEM string 164 | */ 165 | const pemBody = formatPEM(Buffer.from(buffer).toString('base64')) 166 | 167 | // Construct and return the final PEM string 168 | return [`-----BEGIN ${tag}-----`, pemBody, `-----END ${tag}-----`, ''].join( 169 | '\n' 170 | ) 171 | } 172 | 173 | /** 174 | * Encodes a PKCS#10 certification request into an ASN.1 TBS (To-Be-Signed) sequence. 175 | * 176 | * @param pkcs10 The PKCS#10 certification request object to encode. 177 | * @returns An ASN.1 sequence object representing the TBS. 178 | */ 179 | export function encodeTBS(pkcs10: pkijs.CertificationRequest): asn1js.Sequence { 180 | const outputArray = [ 181 | new asn1js.Integer({ value: pkcs10.version }), 182 | pkcs10.subject.toSchema(), 183 | pkcs10.subjectPublicKeyInfo.toSchema(), 184 | ] 185 | 186 | if (pkcs10.attributes !== undefined) { 187 | outputArray.push( 188 | new asn1js.Constructed({ 189 | idBlock: { 190 | tagClass: 3, // CONTEXT-SPECIFIC 191 | tagNumber: 0, // [0] 192 | }, 193 | value: Array.from(pkcs10.attributes, (o) => o.toSchema()), 194 | }) 195 | ) 196 | } 197 | 198 | return new asn1js.Sequence({ 199 | value: outputArray, 200 | }) 201 | } 202 | 203 | /** 204 | * Returns signature parameters based on the private key and hash algorithm 205 | * 206 | * @param privateKey {CryptoKey} The private key used for the signature 207 | * @param hashAlgorithm {string} The hash algorithm used for the signature. Default is "SHA-1". 208 | * @returns {{signatureAlgorithm: pkijs.AlgorithmIdentifier; parameters: pkijs.CryptoEngineAlgorithmParams;}} An object containing signature algorithm and parameters 209 | */ 210 | function getSignatureParameters( 211 | privateKey: CryptoKey, 212 | hashAlgorithm = 'SHA-1' 213 | ): { 214 | signatureAlgorithm: pkijs.AlgorithmIdentifier 215 | parameters: pkijs.CryptoEngineAlgorithmParams 216 | } { 217 | // Check hashing algorithm 218 | pkijs.getOIDByAlgorithm({ name: hashAlgorithm }, true, 'hashAlgorithm') 219 | // Initial variables 220 | const signatureAlgorithm = new pkijs.AlgorithmIdentifier() 221 | 222 | //#region Get a "default parameters" for current algorithm 223 | const parameters = pkijs.getAlgorithmParameters( 224 | privateKey.algorithm.name, 225 | 'sign' 226 | ) 227 | if (!Object.keys(parameters.algorithm).length) { 228 | const errMsg = 'Parameter `algorithm` is empty' 229 | log.error(errMsg) 230 | throw new Error(errMsg) 231 | } 232 | const algorithm = parameters.algorithm 233 | algorithm.hash.name = hashAlgorithm 234 | //#endregion 235 | 236 | //#region Fill internal structures base on "privateKey" and "hashAlgorithm" 237 | switch (privateKey.algorithm.name.toUpperCase()) { 238 | case 'RSASSA-PKCS1-V1_5': 239 | case 'ECDSA': 240 | signatureAlgorithm.algorithmId = pkijs.getOIDByAlgorithm(algorithm, true) 241 | break 242 | case 'RSA-PSS': 243 | { 244 | //#region Set "saltLength" as a length (in octets) of hash function result 245 | switch (hashAlgorithm.toUpperCase()) { 246 | case 'SHA-256': 247 | algorithm.saltLength = 32 248 | break 249 | case 'SHA-384': 250 | algorithm.saltLength = 48 251 | break 252 | case 'SHA-512': 253 | algorithm.saltLength = 64 254 | break 255 | default: 256 | } 257 | //#endregion 258 | 259 | //#region Fill "RSASSA_PSS_params" object 260 | const paramsObject: Partial = {} 261 | 262 | if (hashAlgorithm.toUpperCase() !== 'SHA-1') { 263 | const hashAlgorithmOID = pkijs.getOIDByAlgorithm( 264 | { name: hashAlgorithm }, 265 | true, 266 | 'hashAlgorithm' 267 | ) 268 | 269 | paramsObject.hashAlgorithm = new pkijs.AlgorithmIdentifier({ 270 | algorithmId: hashAlgorithmOID, 271 | algorithmParams: new asn1js.Null(), 272 | }) 273 | 274 | paramsObject.maskGenAlgorithm = new pkijs.AlgorithmIdentifier({ 275 | algorithmId: '1.2.840.113549.1.1.8', // MGF1 276 | algorithmParams: paramsObject.hashAlgorithm.toSchema(), 277 | }) 278 | } 279 | 280 | if (algorithm.saltLength !== 20) 281 | paramsObject.saltLength = algorithm.saltLength 282 | 283 | const pssParameters = new pkijs.RSASSAPSSParams(paramsObject) 284 | //#endregion 285 | 286 | //#region Automatically set signature algorithm 287 | signatureAlgorithm.algorithmId = '1.2.840.113549.1.1.10' 288 | signatureAlgorithm.algorithmParams = pssParameters.toSchema() 289 | //#endregion 290 | } 291 | break 292 | default: 293 | log.error(`Unsupported signature algorithm: ${privateKey.algorithm.name}`) 294 | throw new Error( 295 | `Unsupported signature algorithm: ${privateKey.algorithm.name}` 296 | ) 297 | } 298 | //#endregion 299 | 300 | return { 301 | signatureAlgorithm, 302 | parameters, 303 | } 304 | } 305 | 306 | /** 307 | * Create a Certificate Signing Request 308 | * 309 | * @param {object} params - CSR parameters 310 | * @param {number} [params.keySize] - Size of the newly created private key, default: `2048` 311 | * @param {string} [params.commonName] - Common name 312 | * @param {string[]} [params.altNames] - Alternative names, default: `[]` 313 | * @param {string} [params.country] - Country name 314 | * @param {string} [params.state] - State or province name 315 | * @param {string} [params.locality] - Locality or city name 316 | * @param {string} [params.organization] - Organization name 317 | * @param {string} [params.organizationUnit] - Organization unit name 318 | * @param {string} [params.emailAddress] - Email address 319 | * @returns {Promise<{ pkcs10Ber: ArrayBuffer; keys: Required }>} - Object containing 320 | * the PKCS10 BER representation and generated keys 321 | */ 322 | export async function createCsr(params: { 323 | keySize?: number 324 | commonName: string 325 | altNames: string[] 326 | country?: string 327 | state?: string 328 | locality?: string 329 | organization?: string 330 | organizationUnit?: string 331 | emailAddress?: string 332 | }): Promise<{ pkcs10Ber: ArrayBuffer; keys: Required }> { 333 | // TODO: allow to provide keys in addition to always generating one 334 | const { privateKey, publicKey } = 335 | (await generateKey()) as Required 336 | 337 | const pkcs10 = new pkijs.CertificationRequest() 338 | pkcs10.version = 0 339 | 340 | addSubjectAttributes(pkcs10.subject.typesAndValues, params) 341 | await addExtensions(pkcs10, params, publicKey) 342 | await signCsr(pkcs10, privateKey) 343 | 344 | const pkcs10Ber = getPkcs10Ber(pkcs10) 345 | 346 | return { 347 | pkcs10Ber, 348 | keys: { privateKey, publicKey }, 349 | } 350 | } 351 | 352 | function addSubjectAttributes( 353 | subjectTypesAndValues: pkijs.AttributeTypeAndValue[], 354 | params: { 355 | country?: string 356 | state?: string 357 | organization?: string 358 | organizationUnit?: string 359 | commonName?: string 360 | } 361 | ): void { 362 | if (params.country) { 363 | subjectTypesAndValues.push( 364 | createAttributeTypeAndValue('2.5.4.6', params.country) 365 | ) 366 | } 367 | if (params.state) { 368 | subjectTypesAndValues.push( 369 | createAttributeTypeAndValue('2.5.4.8', params.state) 370 | ) 371 | } 372 | if (params.organization) { 373 | subjectTypesAndValues.push( 374 | createAttributeTypeAndValue('2.5.4.10', params.organization) 375 | ) 376 | } 377 | if (params.organizationUnit) { 378 | subjectTypesAndValues.push( 379 | createAttributeTypeAndValue('2.5.4.11', params.organizationUnit) 380 | ) 381 | } 382 | if (params.commonName) { 383 | subjectTypesAndValues.push( 384 | createAttributeTypeAndValue('2.5.4.3', params.commonName) 385 | ) 386 | } 387 | } 388 | 389 | function createAttributeTypeAndValue( 390 | type: string, 391 | value: string 392 | ): pkijs.AttributeTypeAndValue { 393 | return new pkijs.AttributeTypeAndValue({ 394 | type, 395 | value: new asn1js.Utf8String({ value }), 396 | }) 397 | } 398 | 399 | function getServerNamesAsGeneralNames(params: { 400 | commonName?: string 401 | altNames?: string[] 402 | }): pkijs.GeneralName[] { 403 | const altNames: pkijs.GeneralName[] = [] 404 | 405 | // add common name first so that it becomes the cert common name 406 | if ( 407 | params.commonName && 408 | !altNames.some((name) => name.toString() === params.commonName) 409 | ) { 410 | altNames.push(createGeneralName(2, params.commonName)) 411 | } 412 | 413 | // altNames follow common name 414 | if (params.altNames) { 415 | altNames.push( 416 | ...params.altNames.map((altName) => createGeneralName(2, altName)) 417 | ) 418 | } 419 | 420 | return altNames 421 | } 422 | 423 | function createGeneralName( 424 | type: 0 | 2 | 1 | 6 | 3 | 4 | 7 | 8 | undefined, 425 | value: string 426 | ): pkijs.GeneralName { 427 | return new pkijs.GeneralName({ type, value }) 428 | } 429 | 430 | async function addExtensions( 431 | pkcs10: pkijs.CertificationRequest, 432 | params: { commonName: string; altNames: string[] }, 433 | publicKey: CryptoKey 434 | ) { 435 | await pkcs10.subjectPublicKeyInfo.importKey(publicKey, pkijs.getCrypto(true)) 436 | const subjectKeyIdentifier = await getSubjectKeyIdentifier(pkcs10) 437 | 438 | // Note that the set of AltNames must also include the commonName. 439 | const serverNamesGNs = new pkijs.GeneralNames({ 440 | names: getServerNamesAsGeneralNames(params), 441 | }) 442 | const extensions = new pkijs.Extensions({ 443 | extensions: [ 444 | createExtension('2.5.29.14', subjectKeyIdentifier), // SubjectKeyIdentifier 445 | createExtension('2.5.29.17', serverNamesGNs.toSchema()), // SubjectAltName 446 | ], 447 | }) 448 | pkcs10.attributes = [] 449 | pkcs10.attributes.push( 450 | new pkijs.Attribute({ 451 | type: '1.2.840.113549.1.9.14', // pkcs-9-at-extensionRequest 452 | values: [extensions.toSchema()], 453 | }) 454 | ) 455 | } 456 | 457 | function createExtension( 458 | extnID: string, 459 | extnValue: asn1js.BaseBlock 460 | ): pkijs.Extension { 461 | return new pkijs.Extension({ 462 | extnID, 463 | critical: false, 464 | extnValue: extnValue.toBER(false), 465 | }) 466 | } 467 | 468 | async function getSubjectKeyIdentifier( 469 | pkcs10: pkijs.CertificationRequest 470 | ): Promise { 471 | const subjectPublicKeyValue = 472 | pkcs10.subjectPublicKeyInfo.subjectPublicKey.valueBlock.valueHex 473 | const subjectKeyIdentifier = await crypto.subtle.digest( 474 | 'SHA-256', 475 | subjectPublicKeyValue 476 | ) 477 | return new asn1js.OctetString({ valueHex: subjectKeyIdentifier }) 478 | } 479 | 480 | async function signCsr( 481 | pkcs10: pkijs.CertificationRequest, 482 | privateKey: CryptoKey 483 | ): Promise { 484 | /* Set signatureValue */ 485 | pkcs10.tbsView = new Uint8Array(encodeTBS(pkcs10).toBER()) 486 | const signature = await crypto.subtle.sign( 487 | 'RSASSA-PKCS1-v1_5', 488 | privateKey, 489 | pkcs10.tbsView 490 | ) 491 | pkcs10.signatureValue = new asn1js.BitString({ valueHex: signature }) 492 | 493 | /* Set signatureAlgorithm */ 494 | const signatureParams = getSignatureParameters(privateKey, 'SHA-256') 495 | pkcs10.signatureAlgorithm = signatureParams.signatureAlgorithm 496 | } 497 | 498 | function getPkcs10Ber(pkcs10: pkijs.CertificationRequest): ArrayBuffer { 499 | return pkcs10.toSchema(true).toBER(false) 500 | } 501 | 502 | /** 503 | * Returns the Base64url encoded representation of the input data. 504 | * 505 | * @param {string} data - The data to be encoded. 506 | * @returns {string} - The Base64url encoded representation of the input data. 507 | */ 508 | export function getPemBodyAsB64u(data: string | Buffer): string { 509 | let buf = data 510 | if (typeof data === 'string') { 511 | buf = Buffer.from(data) 512 | } 513 | return buf.toString('base64url') 514 | } 515 | 516 | /** 517 | * Find and format error in response object 518 | * 519 | * @param {object} resp HTTP response 520 | * @returns {string} Error message 521 | */ 522 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any 523 | export function formatResponseError(data: any): string { 524 | let result 525 | // const data = await resp.json(); 526 | if ('error' in data) { 527 | result = data.error.detail || data.error 528 | } else { 529 | result = data.detail || JSON.stringify(data) 530 | } 531 | 532 | return result.replace(/\n/g, '') 533 | } 534 | 535 | /** 536 | * Exponential backoff 537 | * 538 | * https://github.com/mokesmokes/backo 539 | * 540 | * @class 541 | * @param {object} [opts] 542 | * @param {number} [opts.min] Minimum backoff duration in ms 543 | * @param {number} [opts.max] Maximum backoff duration in ms 544 | */ 545 | class Backoff { 546 | min: number 547 | max: number 548 | attempts: number 549 | 550 | constructor({ min = 100, max = 10000 } = {}) { 551 | this.min = min 552 | this.max = max 553 | this.attempts = 0 554 | } 555 | 556 | /** 557 | * Get backoff duration 558 | * 559 | * @returns {number} Backoff duration in ms 560 | */ 561 | 562 | duration() { 563 | const ms = this.min * 2 ** this.attempts 564 | this.attempts += 1 565 | return Math.min(ms, this.max) 566 | } 567 | } 568 | 569 | /** 570 | * Retry promise 571 | * 572 | * @param {function} fn Function returning promise that should be retried 573 | * @param {number} attempts Maximum number of attempts 574 | * @param {Backoff} backoff Backoff instance 575 | * @returns {Promise} 576 | */ 577 | async function retryPromise( 578 | fn: (arg0: () => unknown) => unknown, 579 | attempts: number, 580 | backoff: Backoff 581 | ): Promise { 582 | let aborted = false 583 | 584 | try { 585 | const data = await fn(() => { 586 | aborted = true 587 | }) 588 | return data 589 | } catch (e) { 590 | if (aborted || backoff.attempts + 1 >= attempts) { 591 | throw e 592 | } 593 | 594 | const duration = backoff.duration() 595 | log.info( 596 | `Promise rejected attempt #${backoff.attempts}, retrying in ${duration}ms: ${e}` 597 | ) 598 | 599 | await new Promise((resolve) => { 600 | setTimeout(resolve, duration, {}) 601 | }) 602 | return retryPromise(fn, attempts, backoff) 603 | } 604 | } 605 | 606 | /** 607 | * Retry promise 608 | * 609 | * @param {function} fn Function returning promise that should be retried 610 | * @param {object} [backoffOpts] Backoff options 611 | * @param {number} [backoffOpts.attempts] Maximum number of attempts, default: `5` 612 | * @param {number} [backoffOpts.min] Minimum attempt delay in milliseconds, default: `5000` 613 | * @param {number} [backoffOpts.max] Maximum attempt delay in milliseconds, default: `30000` 614 | * @returns {Promise} 615 | */ 616 | export function retry( 617 | fn: (arg0: () => unknown) => unknown, 618 | { attempts = 5, min = 5000, max = 30000 } = {} 619 | ): Promise { 620 | const backoff = new Backoff({ min, max }) 621 | return retryPromise(fn, attempts, backoff) 622 | } 623 | 624 | /** 625 | * Converts a PEM encoded private key to a CryptoKey object using the WebCrypto API. 626 | * 627 | * @param {string} pem - The PEM encoded private key. 628 | * @returns {Promise} A Promise that resolves with the CryptoKey object. 629 | * @throws {Error} If the key type is not supported or the format is invalid. 630 | */ 631 | export async function importPemPrivateKey(pem: string): Promise { 632 | // Decode PEM string to Uint8Array 633 | const pemData = pemToBuffer(pem, 'PRIVATE KEY') 634 | 635 | // Parse PEM data to ASN.1 structure using pkijs 636 | const asn1 = asn1js.fromBER(pemData.buffer) 637 | const privateKeyInfo = new pkijs.PrivateKeyInfo({ schema: asn1.result }) 638 | 639 | // Use crypto.subtle.importKey to import private key as CryptoKey 640 | const privateKey = await crypto.subtle.importKey( 641 | 'pkcs8', 642 | privateKeyInfo.toSchema().toBER(false), 643 | { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, 644 | true, 645 | ['sign'] 646 | ) 647 | 648 | return privateKey 649 | } 650 | 651 | /** 652 | * Converts a PEM encoded string to a Buffer. 653 | * @param {string} pem Pem encoded input 654 | * @param {string} tag The tag name used to identify the PEM block. 655 | * @returns Buffer 656 | */ 657 | export function pemToBuffer(pem: string, tag: PemTag = 'PRIVATE KEY'): Buffer { 658 | return Buffer.from( 659 | pem.replace( 660 | new RegExp(`(-----BEGIN ${tag}-----|-----END ${tag}-----|\n)`, 'g'), 661 | '' 662 | ), 663 | 'base64' 664 | ) 665 | } 666 | 667 | export type CertificateInfo = { 668 | issuer: { 669 | [x: string]: string 670 | }[] 671 | domains: CertDomains 672 | notBefore: Date 673 | notAfter: Date 674 | } 675 | /** 676 | * Read information from a certificate 677 | * If multiple certificates are chained, the first will be read 678 | * 679 | * @param {buffer|string} certPem PEM encoded certificate or chain 680 | * @returns {CertificateInfo} Certificate info 681 | */ 682 | export async function readCertificateInfo( 683 | certPem: string 684 | ): Promise { 685 | const certBuffer = pemToBuffer(certPem, 'CERTIFICATE') 686 | const cert = pkijs.Certificate.fromBER(certBuffer) 687 | 688 | const issuer = cert.issuer.typesAndValues.map((typeAndValue) => ({ 689 | [typeAndValue.type]: typeAndValue.value.valueBlock.value, 690 | })) 691 | 692 | return { 693 | issuer, 694 | domains: readX509ServerNames(certPem), 695 | notBefore: cert.notBefore.value, 696 | notAfter: cert.notAfter.value, 697 | } 698 | } 699 | 700 | /** 701 | * Split chain of PEM encoded objects from string into array 702 | * 703 | * @param {buffer|string} chainPem PEM encoded object chain 704 | * @returns {array} Array of PEM objects including headers 705 | */ 706 | export function splitPemChain(chainPem: Buffer | string): (string | null)[] { 707 | if (Buffer.isBuffer(chainPem)) { 708 | chainPem = chainPem.toString() 709 | } 710 | return ( 711 | chainPem 712 | /* Split chain into chunks, starting at every header */ 713 | .split(/\s*(?=-----BEGIN [A-Z0-9- ]+-----\r?\n?)/g) 714 | /* Match header, PEM body and footer */ 715 | .map((pem) => 716 | pem.match( 717 | /\s*-----BEGIN ([A-Z0-9- ]+)-----\r?\n?([\S\s]+)\r?\n?-----END \1-----/ 718 | ) 719 | ) 720 | /* Filter out non-matches or empty bodies */ 721 | .filter((pem) => pem && pem[2] && pem[2].replace(/[\r\n]+/g, '').trim()) 722 | .map((arr) => arr && arr[0]) 723 | ) 724 | } 725 | 726 | export type CertDomains = { 727 | commonName: string 728 | altNames: string[] 729 | } 730 | /** 731 | * Given a `domains` object, which follows the format returned by readX509ServerNames(), 732 | * returns 733 | * @param domains CertDomains 734 | */ 735 | export function uniqueDomains(domains: CertDomains): string[] { 736 | const uniqueDomains = [domains.commonName] 737 | for (const altName of domains.altNames) { 738 | if (uniqueDomains.indexOf(altName) === -1) { 739 | uniqueDomains.push(altName) 740 | } 741 | } 742 | return uniqueDomains 743 | } 744 | 745 | /** 746 | * Reads the common name and alternative names from a PEM-formatted cert or CSR 747 | * (Certificate Signing Request). 748 | * @param certPem The PEM-encoded cert or CSR string or a Buffer containing the same. 749 | * @returns An object with the commonName and altNames extracted from the cert/CSR. 750 | * If the cert does not have alternative names, altNames will be empty. 751 | */ 752 | export function readX509ServerNames(certPem: string | Buffer): CertDomains { 753 | if (Buffer.isBuffer(certPem)) { 754 | certPem = certPem.toString() 755 | } 756 | const csr = x509.parse_pem_cert(certPem) 757 | 758 | // for some reason, get_oid_value for altNames returns a nested array, e.g. 759 | // [['host1','host2']], so make it a normal array if necessary 760 | let altNames: string[] = [] 761 | const origAltNames = x509.get_oid_value( 762 | csr, 763 | '2.5.29.17' 764 | ) as unknown as string[][] 765 | if (origAltNames && origAltNames[0] && origAltNames[0][0]) { 766 | altNames = origAltNames[0] 767 | } 768 | 769 | return { 770 | commonName: x509.get_oid_value(csr, '2.5.4.3'), 771 | altNames, 772 | } 773 | } 774 | 775 | /** 776 | * Convenience method to return the value of a given environment variable or 777 | * nginx variable. Will return the environment variable if that is found first. 778 | * Requires that env vars be the uppercase version of nginx vars. 779 | * If no default is given and the variable is not found, throws an error. 780 | * @param r Nginx HTTP Request 781 | * @param varname Name of the variable 782 | * @returns value of the variable 783 | */ 784 | export function getVariable( 785 | r: NginxHTTPRequest | NginxPeriodicSession, 786 | varname: 787 | | 'njs_acme_account_email' 788 | | 'njs_acme_server_names' 789 | | 'njs_acme_dir' 790 | | 'njs_acme_challenge_dir' 791 | | 'njs_acme_account_private_jwk' 792 | | 'njs_acme_directory_uri' 793 | | 'njs_acme_verify_provider_https' 794 | | 'njs_acme_shared_dict_zone_name', 795 | defaultVal?: string 796 | ): string { 797 | const retval = 798 | process.env[varname.toUpperCase()] || r.variables[varname] || defaultVal 799 | if (retval === undefined) { 800 | const errMsg = `Variable ${varname} not found and no default value given.` 801 | log.error(errMsg) 802 | throw new Error(errMsg) 803 | } 804 | return retval 805 | } 806 | 807 | /** 808 | * Return the hostname to use as the common name for issued certs. This is the first hostname in the njs_acme_server_names variable. 809 | * @param r request 810 | * @returns {string} hostname 811 | */ 812 | export function acmeCommonName( 813 | r: NginxHTTPRequest | NginxPeriodicSession 814 | ): string { 815 | // The first name is the common name 816 | return acmeServerNames(r)[0] 817 | } 818 | 819 | /** 820 | * Return the hostname to use as the common name for issued certs. This is the first hostname in the njs_acme_server_names variable. 821 | * @param r request 822 | * @returns {string} hostname 823 | */ 824 | export function acmeAltNames( 825 | r: NginxHTTPRequest | NginxPeriodicSession 826 | ): string[] { 827 | const serverNames = acmeServerNames(r) 828 | if (serverNames.length <= 1) { 829 | // no alt names 830 | return [] 831 | } 832 | // Return everything after the first name 833 | return serverNames.slice(1) 834 | } 835 | 836 | /** 837 | * Return an array of hostnames specified in the njs_acme_server_names variable 838 | * @param r request 839 | * @returns array of hostnames 840 | */ 841 | export function acmeServerNames( 842 | r: NginxHTTPRequest | NginxPeriodicSession 843 | ): string[] { 844 | const nameStr = getVariable(r, 'njs_acme_server_names') // no default == mandatory 845 | // split string value on comma and/or whitespace and lowercase each element 846 | const names = nameStr.split(/[,\s]+/) 847 | const invalidNames = names.filter((name) => !isValidHostname(name)) 848 | 849 | if (invalidNames.length > 0) { 850 | const errMsg = 851 | 'Invalid hostname(s) in `njs_acme_server_names` detected: ' + 852 | invalidNames.join(', ') 853 | log.error(errMsg) 854 | throw new Error(errMsg) 855 | } 856 | return names.map((n) => n.toLowerCase()) 857 | } 858 | 859 | /** 860 | * Return the path where ACME magic happens 861 | * @param r request 862 | * @returns configured path or default 863 | */ 864 | export function acmeDir(r: NginxHTTPRequest | NginxPeriodicSession): string { 865 | return getVariable(r, 'njs_acme_dir', '/etc/nginx/njs-acme') 866 | } 867 | 868 | /** 869 | * Return the shared_dict zone name 870 | * @param r request 871 | * @returns configured shared_dict zone name or default 872 | */ 873 | export function acmeZoneName( 874 | r: NginxHTTPRequest | NginxPeriodicSession 875 | ): string { 876 | return getVariable(r, 'njs_acme_shared_dict_zone_name', 'acme') 877 | } 878 | /** 879 | * Return the path where ACME challenges are stored 880 | * @param r request 881 | * @returns configured path or default 882 | */ 883 | export function acmeChallengeDir( 884 | r: NginxHTTPRequest | NginxPeriodicSession 885 | ): string { 886 | return getVariable( 887 | r, 888 | 'njs_acme_challenge_dir', 889 | joinPaths(acmeDir(r), 'challenge') 890 | ) 891 | } 892 | 893 | /** 894 | * Returns the path for the account private JWK 895 | * @param r {NginxHTTPRequest | NginxPeriodicSession} 896 | */ 897 | export function acmeAccountPrivateJWKPath( 898 | r: NginxHTTPRequest | NginxPeriodicSession 899 | ): string { 900 | return getVariable( 901 | r, 902 | 'njs_acme_account_private_jwk', 903 | joinPaths(acmeDir(r), 'account_private_key.json') 904 | ) 905 | } 906 | 907 | /** 908 | * Returns the ACME directory URI 909 | * @param r {NginxHTTPRequest | NginxPeriodicSession} 910 | */ 911 | export function acmeDirectoryURI( 912 | r: NginxHTTPRequest | NginxPeriodicSession 913 | ): string { 914 | return getVariable( 915 | r, 916 | 'njs_acme_directory_uri', 917 | 'https://acme-staging-v02.api.letsencrypt.org/directory' 918 | ) 919 | } 920 | 921 | /** 922 | * Returns whether to verify the ACME provider HTTPS certificate and chain 923 | * @param r {NginxHTTPRequest | NginxPeriodicSession} 924 | * @returns boolean 925 | */ 926 | export function acmeVerifyProviderHTTPS( 927 | r: NginxHTTPRequest | NginxPeriodicSession 928 | ): boolean { 929 | return ( 930 | ['true', 'yes', '1'].indexOf( 931 | getVariable(r, 'njs_acme_verify_provider_https', 'true') 932 | .toLowerCase() 933 | .trim() 934 | ) > -1 935 | ) 936 | } 937 | 938 | export function areEqualSets(arr1: string[], arr2: string[]): boolean { 939 | if (arr1.length !== arr2.length) { 940 | return false 941 | } 942 | for (const elem of arr1) { 943 | if (arr2.indexOf(elem) === -1) { 944 | return false 945 | } 946 | } 947 | return true 948 | } 949 | 950 | /** 951 | * Joins args with slashes and removes duplicate slashes 952 | * @param args path fragments to join 953 | * @returns joined path string 954 | */ 955 | export function joinPaths(...args: string[]): string { 956 | // join args with a slash remove duplicate slashes 957 | return args.join('/').replace(/\/+/g, '/') 958 | } 959 | 960 | export function isValidHostname(hostname: string): boolean { 961 | return ( 962 | !!hostname && 963 | hostname.length < 256 && 964 | !!hostname.match( 965 | // hostnames are dot-separated groups of letters, numbers, hyphens (but 966 | // not beginning or ending with hyphens), and may end with a period 967 | /^[a-z\d]([-a-z\d]{0,61}[a-z\d])?(\.[a-z\d]([-a-z\d]{0,61}[a-z\d])?)*\.?$/i 968 | ) 969 | ) 970 | } 971 | 972 | /** 973 | * Return the certificate 974 | * @param {NginxHTTPRequest} r - The Nginx HTTP request object. 975 | * @returns {string} - The contents of the cert or key 976 | */ 977 | export function readCert(r: NginxHTTPRequest): string { 978 | return readCertOrKey(r, CERTIFICATE_SUFFIX) 979 | } 980 | 981 | /** 982 | * Return the certificate 983 | * @param {NginxHTTPRequest} r - The Nginx HTTP request object. 984 | * @returns {string} - The contents of the cert or key 985 | */ 986 | export function readKey(r: NginxHTTPRequest): string { 987 | return readCertOrKey(r, KEY_SUFFIX) 988 | } 989 | 990 | /** 991 | * Given a request and suffix that indicates whether the caller wants the cert 992 | * or key, return the requested object from cache if possible, falling back to 993 | * disk. 994 | * @param {NginxHTTPRequest} r The Nginx HTTP request object. 995 | * @param {string} suffix The file suffix that indicates whether we want a cert or key 996 | * @returns {string} The contents of the cert or key 997 | */ 998 | function readCertOrKey( 999 | r: NginxHTTPRequest, 1000 | suffix: typeof CERTIFICATE_SUFFIX | typeof KEY_SUFFIX 1001 | ): string { 1002 | let data = '' 1003 | const base = certOrKeyBase(r) 1004 | const path = base + suffix 1005 | const key = cacheKey(path) 1006 | 1007 | const cache = ngxSharedDict(r) 1008 | 1009 | // ensure the shared dict zone is configured before checking cache 1010 | if (cache) { 1011 | data = (cache.get(key) as string) || '' 1012 | if (data) { 1013 | // Return cached value 1014 | return data 1015 | } 1016 | } 1017 | 1018 | // filesystem fallback 1019 | try { 1020 | data = fs.readFileSync(path, 'utf8') 1021 | } catch (e) { 1022 | log.error('error reading from file:', path, `. Error=${e}`) 1023 | return '' 1024 | } 1025 | // try caching value read from disk in the shared dict zone, if configured 1026 | if (cache && data) { 1027 | const zone = acmeZoneName(r) 1028 | try { 1029 | cache.set(key, data) 1030 | log.debug(`wrote to cache: ${key} zone: ${zone}`) 1031 | } catch (e) { 1032 | const errMsg = `error writing to shared dict zone: ${zone}. Error=${e}` 1033 | log.error(errMsg) 1034 | } 1035 | } 1036 | return data 1037 | } 1038 | 1039 | /** 1040 | * Returns the NGINX shared dict zone if configured. 1041 | * @param r - The request or periodic session 1042 | * @returns Shared dict zone or `null` 1043 | */ 1044 | function ngxSharedDict( 1045 | r: NginxHTTPRequest | NginxPeriodicSession 1046 | ): NgxSharedDict | null { 1047 | const zone = acmeZoneName(r) 1048 | if (zone && ngx.shared) { 1049 | const sharedZone = ngx.shared[zone] 1050 | if (sharedZone) { 1051 | return sharedZone 1052 | } 1053 | } 1054 | return null 1055 | } 1056 | 1057 | /** 1058 | * Removes cached cert and key from the shared dict zone, if applicable. 1059 | * @param r - The request or periodic session 1060 | */ 1061 | export function purgeCachedCertKey( 1062 | r: NginxHTTPRequest | NginxPeriodicSession 1063 | ): void { 1064 | const objPrefix = certOrKeyBase(r) 1065 | const cache = ngxSharedDict(r) 1066 | 1067 | if (cache) { 1068 | cache.delete(cacheKey(objPrefix + CERTIFICATE_SUFFIX)) 1069 | cache.delete(cacheKey(objPrefix + KEY_SUFFIX)) 1070 | } 1071 | } 1072 | 1073 | /** 1074 | * Prepend our namespace to a given cache key 1075 | * @param key Path to the cert or key 1076 | * @returns Shared dict cache ke 1077 | */ 1078 | function cacheKey(key: string) { 1079 | return 'acme:' + key 1080 | } 1081 | 1082 | /** 1083 | * Returns the base path to store a cert or key 1084 | * @param path Path to the cert or key 1085 | * @returns Path string 1086 | */ 1087 | function certOrKeyBase(r: NginxHTTPRequest | NginxPeriodicSession): string { 1088 | const prefix = acmeDir(r) 1089 | const commonName = acmeCommonName(r) 1090 | const path = joinPaths(prefix, commonName) 1091 | return path 1092 | } 1093 | --------------------------------------------------------------------------------