diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md deleted file mode 100644 index 34a4e088..00000000 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -name: Bug Report -about: Create a report to help us improve -title: '' -labels: bug, needs triage -assignees: '' - ---- - -### Subject of the issue -Describe your issue here. - -### Your environment -* OS - -* Version - - -### Steps to reproduce -Tell us how to reproduce this issue. Please provide a working demo, you can use [this template](https://plnkr.co/edit/XorWgI?p=preview) as a base. - -### Expected behaviour -Tell us what should happen - -### Actual behaviour -Tell us what happens instead - -### Additional context -Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 00000000..2b942a47 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,56 @@ +name: Bug Report +description: File a bug report +title: "[Bug]: " +labels: ["bug", "needs triage"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + - type: textarea + id: steps + attributes: + label: Steps to Reproduce + description: Tell us how to reproduce this issue. + placeholder: These are the steps! + validations: + required: true + - type: textarea + id: your-env + attributes: + label: Your Environment + value: |- + * OS - + * `step` CLI Version - + validations: + required: true + - type: textarea + id: expected-behavior + attributes: + label: Expected Behavior + description: What did you expect to happen? + validations: + required: true + - type: textarea + id: actual-behavior + attributes: + label: Actual Behavior + description: What happens instead? + validations: + required: true + - type: textarea + id: context + attributes: + label: Additional Context + description: Add any other context about the problem here. + validations: + required: false + - type: textarea + id: contributing + attributes: + label: Contributing + value: | + Vote on this issue by adding a 👍 reaction. + To contribute a fix for this issue, leave a comment (and link to your pull request, if you've opened one already). + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/documentation-request.md b/.github/ISSUE_TEMPLATE/documentation-request.md index a8e0a9ea..2e372b05 100644 --- a/.github/ISSUE_TEMPLATE/documentation-request.md +++ b/.github/ISSUE_TEMPLATE/documentation-request.md @@ -1,12 +1,20 @@ --- name: Documentation Request about: Request documentation for a feature -title: '' +title: '[docs]:' labels: docs, needs triage assignees: '' --- +## Hello! + + +- Vote on this issue by adding a 👍 reaction +- If you want to document this feature, comment to let us know (we'll work with you on design, scheduling, etc.) + +## Affected area/feature + +- Vote on this issue by adding a 👍 reaction +- If you want to implement this feature, comment to let us know (we'll work with you on design, scheduling, etc.) -### Why this is needed +## Issue details + + + +## Why is this needed? + + diff --git a/.github/PULL_REQUEST_TEMPLATE b/.github/PULL_REQUEST_TEMPLATE index 266e9124..5d38f102 100644 --- a/.github/PULL_REQUEST_TEMPLATE +++ b/.github/PULL_REQUEST_TEMPLATE @@ -1,4 +1,20 @@ -### Description -Please describe your pull request. + +#### Name of feature: + +#### Pain or issue this feature alleviates: + +#### Why is this important to the project (if not answered above): + +#### Is there documentation on how to use this feature? If so, where? + +#### In what environments or workflows is this feature supported? + +#### In what environments or workflows is this feature explicitly NOT supported (if any)? + +#### Supporting links/other PRs/issues: 💔Thank you! diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..a9618d0b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "gomod" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/labeler.yml b/.github/labeler.yml deleted file mode 100644 index 538aed15..00000000 --- a/.github/labeler.yml +++ /dev/null @@ -1,4 +0,0 @@ -needs triage: - - '**' # index.php | src/main.php - - '.*' # .gitignore - - '.*/**' # .github/workflows/label.yml diff --git a/.github/workflows/actionci.yml b/.github/workflows/actionci.yml new file mode 100644 index 00000000..f8482e66 --- /dev/null +++ b/.github/workflows/actionci.yml @@ -0,0 +1,22 @@ +name: Action CI + +on: + push: + tags-ignore: + - 'v*' + branches: + - "master" + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + actionci: + permissions: + contents: read + actions: read + security-events: write + uses: smallstep/workflows/.github/workflows/actionci.yml@main + secrets: inherit diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..7ea60dac --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + push: + tags-ignore: + - 'v*' + branches: + - "master" + pull_request: + workflow_call: + secrets: + CODECOV_TOKEN: + required: true + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + ci: + permissions: + actions: read + contents: read + security-events: write + uses: smallstep/workflows/.github/workflows/goCI.yml@main + with: + only-latest-golang: false + run-codeql: true + golangci-lint-version: "v2.12.1" + secrets: inherit diff --git a/.github/workflows/code-scan-cron.yml b/.github/workflows/code-scan-cron.yml new file mode 100644 index 00000000..5b9bd912 --- /dev/null +++ b/.github/workflows/code-scan-cron.yml @@ -0,0 +1,14 @@ +name: Code Scan Cron + +on: + schedule: + - cron: '0 0 * * *' + +jobs: + code-scan: + permissions: + actions: read + contents: read + security-events: write + uses: smallstep/workflows/.github/workflows/code-scan.yml@main + secrets: inherit diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 00000000..b145ea96 --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,10 @@ +name: Dependabot auto-merge +on: pull_request + +permissions: + pull-requests: read + +jobs: + dependabot-auto-merge: + uses: smallstep/workflows/.github/workflows/dependabot-auto-merge.yml@main + secrets: inherit diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml deleted file mode 100644 index dee30e96..00000000 --- a/.github/workflows/labeler.yml +++ /dev/null @@ -1,11 +0,0 @@ -name: Pull Request Labeler -on: - pull_request_target - -jobs: - label: - runs-on: ubuntu-latest - steps: - - uses: actions/labeler@v3.0.2 - with: - repo-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml new file mode 100644 index 00000000..8474c355 --- /dev/null +++ b/.github/workflows/publish-packages.yml @@ -0,0 +1,77 @@ +name: Publish to packages.smallstep.com + +# Independently publish packages to Red Hat (RPM) and Debian (DEB) repositories +# without running a full release. Downloads packages from GitHub releases, +# uploads to GCS, and imports to Artifact Registry. +# +# Usage (CLI): +# gh workflow run publish-packages.yml -f tag=v0.28.0 + +on: + workflow_dispatch: + inputs: + tag: + description: 'Git tag to publish (e.g., v0.28.0)' + required: true + type: string + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.tag }} + fetch-depth: 0 + + - name: Extract version + id: version + run: echo "version=${TAG#v}">> "$GITHUB_OUTPUT" + env: + TAG: ${{ inputs.tag }} + + - name: Is Pre-release + id: is_prerelease + run: | + if [[ "$TAG" == *"-rc"* ]]; then + echo "is_prerelease=true">> "$GITHUB_OUTPUT" + else + echo "is_prerelease=false">> "$GITHUB_OUTPUT" + fi + env: + TAG: ${{ inputs.tag }} + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 + with: + workload_identity_provider: ${{ secrets.GOOGLE_CLOUD_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ secrets.GOOGLE_CLOUD_GITHUB_SERVICE_ACCOUNT }} + + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1 + with: + project_id: ${{ secrets.GOOGLE_CLOUD_PACKAGES_PROJECT_ID }} + + - name: Download packages from GitHub release + run: | + mkdir -p dist + gh release download "$TAG" --pattern "*${VERSION}*.deb" --pattern "*${VERSION}*.rpm" --dir dist + env: + TAG: ${{ inputs.tag }} + VERSION: ${{ steps.version.outputs.version }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload packages to GCS + run: | + for pkg in dist/*.deb dist/*.rpm; do + ./scripts/package-upload.sh "$pkg" step-cli ${{ steps.version.outputs.version }} + done + + - name: Import packages to Artifact Registry + run: ./scripts/package-repo-import.sh step-cli ${{ steps.version.outputs.version }} + env: + IS_PRERELEASE: ${{ steps.is_prerelease.outputs.is_prerelease }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d7906cdc..5781e95a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,243 +6,174 @@ on: tags: - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 +permissions: + contents: read + jobs: - test: - name: Lint, Test, Build - runs-on: ubuntu-20.04 - strategy: - matrix: - go: [ '1.16', '1.17' ] - steps: - - - name: Checkout - uses: actions/checkout@v2 - - - name: Setup Go - uses: actions/setup-go@v2 - with: - go-version: ${{ matrix.go }} - - - name: golangci-lint - uses: golangci/golangci-lint-action@v2 - with: - version: 'v1.43.0' - args: --timeout=30m - - - name: Test, Build - id: lintTestBuild - run: V=1 make ci - - create_release: - name: Create Release - needs: test - runs-on: ubuntu-20.04 + ci: + permissions: + actions: read + contents: read + security-events: write + uses: ./.github/workflows/ci.yml + secrets: inherit + + release_metadata: + name: Release Metadata + permissions: + contents: read + needs: ci + runs-on: ubuntu-latest + env: + DOCKER_IMAGE: smallstep/step-cli + DEBIAN_TAG: trixie outputs: version: ${{ steps.extract-tag.outputs.VERSION }} vversion: ${{ steps.extract-tag.outputs.VVERSION }} - debversion: ${{ steps.extract-tag.outputs.DEB_VERSION }} is_prerelease: ${{ steps.is_prerelease.outputs.IS_PRERELEASE }} + docker_tags: ${{ env.DOCKER_TAGS }} + docker_tags_debian: ${{ env.DOCKER_TAGS_DEBIAN }} steps: - - - name: Extract Tag Names - id: extract-tag - run: | - VVERSION=${GITHUB_REF#refs/tags/} - VERSION=${GITHUB_REF#refs/tags/v} - DEB_VERSION=$(echo ${VERSION} | sed 's/-/./') - echo "::set-output name=VVERSION::${VVERSION}" - echo "::set-output name=VERSION::${VERSION}" - echo "::set-output name=DEB_VERSION::${DEB_VERSION}" - - - name: Is Pre-release + - name: Is Pre-release id: is_prerelease + env: + REF: ${{ github.ref }} run: | set +e - echo ${{ github.ref }} | grep "\-rc.*" + echo "${REF}" | grep "\-rc.*" OUT=$? if [ $OUT -eq 0 ]; then IS_PRERELEASE=true; else IS_PRERELEASE=false; fi - echo "::set-output name=IS_PRERELEASE::${IS_PRERELEASE}" - - - name: Create Release - id: create_release - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ github.ref }} - release_name: Release ${{ github.ref }} - draft: false - prerelease: ${{ steps.is_prerelease.outputs.IS_PRERELEASE }} - - goreleaser: - name: Upload Assets to Github w/ goreleaser - runs-on: ubuntu-20.04 - needs: create_release - steps: - - - name: Checkout - uses: actions/checkout@v2 - with: - fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@v2 - with: - go-version: 1.17 - - - name: APT Install - id: aptInstall - run: sudo apt-get -y install build-essential debhelper fakeroot - - - name: Build Debian package - id: make_debian + echo "IS_PRERELEASE=${IS_PRERELEASE}">> "${GITHUB_OUTPUT}" + - name: Extract Tag Names + id: extract-tag run: | - PATH=$PATH:/usr/local/go/bin:/home/admin/go/bin - make debian - # need to restore the git state otherwise goreleaser fails due to dirty state - git restore debian/changelog - git clean -fd - - - name: Install cosign - uses: sigstore/cosign-installer@v1.1.0 - with: - cosign-release: 'v1.1.0' - - - name: Write cosign key to disk - id: write_key - run: echo "${{ secrets.COSIGN_KEY }}"> "/tmp/cosign.key" - - - name: Get Release Date - id: release_date + VVERSION=${GITHUB_REF#refs/tags/} + VERSION=${GITHUB_REF#refs/tags/v} + echo "VVERSION=${VVERSION}">> "${GITHUB_OUTPUT}" + echo "VERSION=${VERSION}">> "${GITHUB_OUTPUT}" + echo "DOCKER_TAGS=${{ env.DOCKER_IMAGE }}:${VERSION}">> "${GITHUB_ENV}" + echo "DOCKER_TAGS_DEBIAN=${{ env.DOCKER_IMAGE }}:${VERSION}-${DEBIAN_TAG}">> "${GITHUB_ENV}" + - name: Add Latest Tag + if: steps.is_prerelease.outputs.IS_PRERELEASE == 'false' run: | - RELEASE_DATE=$(date +"%y-%m-%d") - echo "::set-output name=RELEASE_DATE::${RELEASE_DATE}" - - - name: Run GoReleaser - uses: goreleaser/goreleaser-action@5a54d7e660bda43b405e8463261b3d25631ffe86 # v2.7.0 - with: - version: latest - args: release --rm-dist - env: - GITHUB_TOKEN: ${{ secrets.PAT }} - COSIGN_PWD: ${{ secrets.COSIGN_PWD }} - DEB_VERSION: ${{ needs.create_release.outputs.debversion }} - RELEASE_DATE: ${{ steps.release_date.outputs.RELEASE_DATE }} + echo "DOCKER_TAGS=${{ env.DOCKER_TAGS }},${{ env.DOCKER_IMAGE }}:latest">> "${GITHUB_ENV}" + echo "DOCKER_TAGS_DEBIAN=${{ env.DOCKER_TAGS_DEBIAN }},${{ env.DOCKER_IMAGE }}:${DEBIAN_TAG}">> "${GITHUB_ENV}" + + goreleaser: + needs: release_metadata + permissions: + id-token: write + contents: write + packages: write + uses: smallstep/workflows/.github/workflows/goreleaser.yml@main + with: + enable-packages-upload: true + is-prerelease: ${{ needs.release_metadata.outputs.is_prerelease == 'true' }} + secrets: inherit build_upload_docker: name: Build & Upload Docker Images - runs-on: ubuntu-20.04 - needs: test - steps: - - - name: Checkout - uses: actions/checkout@v2 - - - name: Setup Go - uses: actions/setup-go@v2 - with: - go-version: 1.17 - - - name: Install cosign - uses: sigstore/cosign-installer@v1.1.0 - with: - cosign-release: 'v1.1.0' - - - name: Write cosign key to disk - id: write_key - run: echo "${{ secrets.COSIGN_KEY }}"> "/tmp/cosign.key" - - - name: Build - id: build - run: | - PATH=$PATH:/usr/local/go/bin:/home/admin/go/bin - make docker-artifacts - env: - DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} - DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} - COSIGN_PWD: ${{ secrets.COSIGN_PWD }} + needs: release_metadata + permissions: + id-token: write + contents: read + uses: smallstep/workflows/.github/workflows/docker-buildx-push.yml@main + with: + platforms: linux/amd64,linux/386,linux/arm,linux/arm64 + tags: ${{ needs.release_metadata.outputs.docker_tags }} + docker_image: smallstep/step-cli + docker_file: docker/Dockerfile + secrets: inherit -# All jobs below this are for full releases (non release candidates e.g. *-rc.*) + build_upload_docker_debian: + name: Build & Upload Docker Images using Debian + needs: release_metadata + permissions: + id-token: write + contents: read + uses: smallstep/workflows/.github/workflows/docker-buildx-push.yml@main + with: + platforms: linux/amd64,linux/386,linux/arm,linux/arm64 + tags: ${{ needs.release_metadata.outputs.docker_tags_debian }} + docker_image: smallstep/step-cli + docker_file: docker/Dockerfile.debian + secrets: inherit - build_upload_aws_s3_binaries: - name: Build & Upload AWS S3 Binaries - runs-on: ubuntu-20.04 - needs: create_release - if: needs.create_release.outputs.is_prerelease == 'false' - steps: - - - name: Checkout - uses: actions/checkout@v2 - - - name: Setup Go - uses: actions/setup-go@v2 - with: - go-version: 1.17 - - - name: Build - id: build - run: | - PATH=$PATH:/usr/local/go/bin:/home/admin/go/bin - make -j1 binary-linux binary-darwin binary-windows - mkdir -p ./.releases - cp ./output/binary/linux/bin/step ./.releases/step-linux-${{ needs.create_release.outputs.version }} - cp ./output/binary/linux/bin/step ./.releases/step-linux-latest - cp ./output/binary/darwin/bin/step ./.releases/step-darwin-${{ needs.create_release.outputs.version }} - cp ./output/binary/darwin/bin/step ./.releases/step-darwin-latest - cp ./output/binary/windows/bin/step ./.releases/step-windows-${{ needs.create_release.outputs.version }}.exe - cp ./output/binary/windows/bin/step ./.releases/step-windows-latest.exe - - - name: Upload s3 - id: upload-s3 - uses: jakejarvis/s3-sync-action@v0.5.1 - with: - args: --acl public-read --follow-symlinks - env: - AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }} - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - AWS_REGION: us-east-1 - SOURCE_DIR: ./.releases +# All jobs below this are for full releases (non release candidates e.g. *-rc.*) update_reference_docs: name: Update Reference Docs - runs-on: ubuntu-20.04 - needs: create_release - if: needs.create_release.outputs.is_prerelease == 'false' + permissions: + contents: read + runs-on: ubuntu-latest + needs: release_metadata + if: needs.release_metadata.outputs.is_prerelease == 'false' steps: - - - name: Checkout - uses: actions/checkout@v2 - - - name: Setup Go - uses: actions/setup-go@v2 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Setup Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: 1.17 - - - name: Build + go-version: 'stable' + check-latest: true + - name: Build id: build run: V=1 make build - - - name: Checkout Docs - uses: actions/checkout@master + - name: Checkout Docs + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: smallstep/docs - token: ${{ secrets.PAT }} + token: ${{ secrets.DOCS_PAT }} path: './docs' - - - name: Update Reference - id: update_refrence + - name: Setup bot SSH signing key + uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0 + env: + HAS_SSH_PRIVATE_KEY: ${{ secrets.STEP_TRAVIS_CI_GH_PRIVATE_SIGNING_KEY != '' }} + if: ${{ env.HAS_SSH_PRIVATE_KEY == 'true' }} + with: + ssh-private-key: | + ${{ secrets.STEP_TRAVIS_CI_GH_PRIVATE_SIGNING_KEY }} + - name: Update Reference + id: update_reference run: | - ./bin/step help --markdown ./docs/src/pages/docs/step-cli/reference cd ./docs - git config user.email "eng@smallstep.com" - git config user.name "Github Action CI" - git add . && git commit -a -m "step-cli ${{ needs.create_release.outputs.vversion }} reference update" - - - name: Push changes - uses: ad-m/github-push-action@v0.6.0 + + git config user.email "eng+ci@smallstep.com" + git config user.name "step-ci" + + # Configure GH commit signing key. + git config --global commit.gpgsign true + git config --global gpg.format ssh + git config --global user.signingkey "${{ secrets.STEP_TRAVIS_CI_GH_PUBLIC_SIGNING_KEY }}" + + # Remove old docs + git rm -rf ./step-cli/reference + + # Build fresh docs + ../bin/step help --markdown ./step-cli/reference + + # Generate new route manifest for the docs + find step-cli/reference -mindepth 2 -type f | jq -R -s '[ + split("\n")[:-1][] + | {hideFromSidebar: true, + title: "", + path: ("/" + .)} + ]'> "$RUNNER_TEMP/reference-routes.json" + + # Replace old route manifest with new + jq --slurpfile newRoutes "$RUNNER_TEMP/reference-routes.json" 'walk( + if type == "object" and .isStepReference == true then + .routes = $newRoutes[0] + else . end + )' < manifest.json> manifest.json.new + + mv manifest.json.new manifest.json + + git add . && git commit -a -m "step-cli ${{ needs.release_metadata.outputs.vversion }} reference update" + - name: Push changes + uses: ad-m/github-push-action@881a6320fdb16eb5318c5054f31c218aec2b324c # v1.3.0 with: - github_token: ${{ secrets.PAT }} + github_token: ${{ secrets.DOCS_PAT }} branch: 'main' directory: './docs' repository: 'smallstep/docs' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index d6fe1344..00000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Lint, Test, Build - -on: - push: - tags-ignore: - - 'v*' - branches: - - "**" - pull_request: - -jobs: - lintTestBuild: - name: Lint, Test, Build - runs-on: ubuntu-20.04 - strategy: - matrix: - go: [ '1.16', '1.17' ] - steps: - - - name: Checkout - uses: actions/checkout@v2 - - - name: Setup Go - uses: actions/setup-go@v2 - with: - go-version: ${{ matrix.go }} - - - name: golangci-lint - uses: golangci/golangci-lint-action@v2 - with: - version: 'v1.43.0' - args: --timeout=30m - - - name: Test, Build - id: lintTestBuild - run: V=1 make ci - - - name: Codecov - uses: codecov/codecov-action@v1.2.1 - if: matrix.go == '1.17' - with: - file: ./coverage.out - name: codecov-umbrella - fail_ci_if_error: true diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml new file mode 100644 index 00000000..9f73ee33 --- /dev/null +++ b/.github/workflows/triage.yml @@ -0,0 +1,20 @@ +name: Add Issues and PRs to Triage + +on: + issues: + types: + - opened + - reopened + pull_request_target: + types: + - opened + - reopened + +permissions: + issues: write + pull-requests: write + +jobs: + triage: + uses: smallstep/workflows/.github/workflows/triage.yml@main + secrets: inherit diff --git a/.github/zizmor.yml b/.github/zizmor.yml new file mode 100644 index 00000000..92a61467 --- /dev/null +++ b/.github/zizmor.yml @@ -0,0 +1,12 @@ +rules: + unpinned-uses: + config: + policies: + "smallstep/*": ref-pin + secrets-inherit: + disable: true + ref-confusion: + disable: true + dangerous-triggers: + ignore: + - triage.yml diff --git a/.gitignore b/.gitignore index fff4bd7c..1ae321b4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,10 @@ *.so *.dylib +# Go Workspaces +go.work +go.work.sum + # Test binary, build with `go test -c` *.test @@ -18,5 +22,11 @@ coverage.txt output vendor +dist/ step .idea +.envrc + +# Packages files +0x889B19391F774443-Certify.key +gha-creds-*.json diff --git a/.golangci.yml b/.golangci.yml deleted file mode 100644 index 5c6faefb..00000000 --- a/.golangci.yml +++ /dev/null @@ -1,73 +0,0 @@ -linters-settings: - govet: - check-shadowing: true - settings: - printf: - funcs: - - (github.com/golangci/golangci-lint/pkg/logutils.Log).Infof - - (github.com/golangci/golangci-lint/pkg/logutils.Log).Errorf - - (github.com/golangci/golangci-lint/pkg/logutils.Log).Warnf - - (github.com/golangci/golangci-lint/pkg/logutils.Log).Fatalf - revive: - min-confidence: 0 - gocyclo: - min-complexity: 10 - maligned: - suggest-new: true - dupl: - threshold: 100 - goconst: - min-len: 2 - min-occurrences: 2 - depguard: - list-type: blacklist - packages: - # logging is allowed only by logutils.Log, logrus - # is allowed to use only in logutils package - - github.com/sirupsen/logrus - misspell: - locale: US - lll: - line-length: 140 - goimports: - local-prefixes: github.com/golangci/golangci-lint - gocritic: - enabled-tags: - - performance - - style - - experimental - - diagnostic - disabled-checks: - - commentFormatting - - commentedOutCode - - hugeParam - - octalLiteral - - rangeValCopy - - tooManyResultsChecker - - unnamedResult - -linters: - disable-all: true - enable: - - deadcode - - gocritic - - gofmt - - gosimple - - govet - - ineffassign - - misspell - - revive - - staticcheck - - structcheck - - unused - -run: - skip-dirs: - - pkg - -issues: - exclude: - - declaration of "err" shadows declaration at line - - should have a package comment, unless it's in another file for this package - - func `CLICommand. - - error strings should not be capitalized or end with punctuation or a newline diff --git a/.goreleaser.yml b/.goreleaser.yml index b2982c12..d5950cdf 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,16 +1,38 @@ -# This is an example .goreleaser.yml file with some sane defaults. -# Make sure to check the documentation at http://goreleaser.com +# Documentation: https://goreleaser.com/customization/ +# yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json +version: 2 project_name: step +# Enable GoReleaser OSS to read Pro configs: https://goreleaser.com/errors/version/#using-a-pro-configuration-file-with-goreleaser-oss +pro: true + +variables: + packageName: step-cli + packageRelease: 1 # Manually update release: in the nfpm section to match this value if you change this + before: hooks: - # You may remove this if you don't use go modules. - go mod download - # - go generate ./... + +after: + hooks: + # This script depends on IS_PRERELEASE env being set. This is set by CI in the Is Pre-release step. + - cmd: bash scripts/package-repo-import.sh {{ .Var.packageName }} {{ .Version }} + output: true builds: - - env: + - &BUILD + id: default + env: - CGO_ENABLED=0 + main: ./cmd/step + flags: + - -trimpath + ldflags: + - -w -X main.Version={{.Version}} -X main.BuildTime={{.Date}} + gcflags: + ->- + {{- if ne (index .Env "DEBUG") "" }}all=-N -l{{- end }} targets: - darwin_amd64 - darwin_arm64 @@ -18,6 +40,7 @@ builds: - linux_386 - linux_amd64 - linux_arm64 + - linux_arm_5 - linux_arm_6 - linux_arm_7 - linux_mips @@ -25,27 +48,96 @@ builds: - linux_ppc64le - windows_amd64 - windows_arm64 - flags: - - -trimpath - main: ./cmd/step/main.go binary: bin/step - ldflags: - - -w -X main.Version={{.Version}} -X main.BuildTime={{.Date}} + - + # This build is specifically for nFPM targets (.deb and .rpm files). + # It's exactly the same as the default build above, except the binary is + # named step-cli. It inherits all Linux targets from the default build. + << : *BUILD + id: nfpm + binary: step-cli archives: - - + - &ARCHIVE + id: default # Can be used to change the archive formats for specific GOOSs. # Most common use case is to archive as zip on Windows. # Default is empty. name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Version }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}{{ if .Mips }}_{{ .Mips }}{{ end }}" format_overrides: - goos: windows - format: zip + formats: ['zip'] + ids: + - default wrap_in_directory: "{{ .ProjectName }}_{{ .Version }}" files: - README.md - LICENSE - autocomplete/* + - + << : *ARCHIVE + id: unversioned + name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}{{ if .Mips }}_{{ .Mips }}{{ end }}" + wrap_in_directory: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}{{ if .Mips }}_{{ .Mips }}{{ end }}" + + +nfpms: + # Configure nFPM for .deb and .rpm releases + # + # See https://nfpm.goreleaser.com/configuration/ + # and https://goreleaser.com/customization/nfpm/ + # + # Useful tools for debugging .debs: + # List file contents: dpkg -c dist/step_...deb + # Package metadata: dpkg --info dist/step_....deb + # + - &NFPM + id: packages + ids: + - nfpm + package_name: "{{ .Var.packageName }}" + release: "1" + file_name_template:>- + {{- trimsuffix .ConventionalFileName .ConventionalExtension -}} + {{- if and (eq .Arm "6") (eq .ConventionalExtension ".deb") }}6{{ end -}} + {{- if not (eq .Amd64 "v1")}}{{ .Amd64 }}{{ end -}} + {{- .ConventionalExtension -}} + vendor: Smallstep Labs + homepage: https://github.com/smallstep/cli + maintainer: Smallstep + description:> + step-cli lets you build, operate, and automate Public Key Infrastructure (PKI) systems and workflows. + + It's a swiss army knife for authenticated encryption (X.509, TLS), single sign-on (OAuth OIDC, SAML), multi-factor authentication (OATH OTP, FIDO U2F), encryption mechanisms (JSON Web Encryption, NaCl), and verifiable claims (JWT, SAML assertions). + license: Apache 2.0 + section: utils + formats: + - deb + - rpm + priority: optional + bindir: /usr/bin + contents: + - src: debian/copyright + dst: /usr/share/doc/step-cli/copyright + # Ghost files are used for RPM and ignored elsewhere + - dst: /usr/bin/step + type: ghost + - dst: /usr/share/bash-completion/completions/step + type: ghost + scripts: + postinstall: scripts/postinstall.sh + postremove: scripts/postremove.sh + rpm: + signature: + key_file: '{{ envOrDefault "GPG_PRIVATE_KEY_FILE" "ENV_VAR_GPG_PRIVATE_KEY_FILE_NOT_SET" }}' + deb: + signature: + key_file: '{{ envOrDefault "GPG_PRIVATE_KEY_FILE" "ENV_VAR_GPG_PRIVATE_KEY_FILE_NOT_SET" }}' + type: origin + - + << : *NFPM + id: unversioned + file_name_template: "{{ .PackageName }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}{{ if .Mips }}_{{ .Mips }}{{ end }}" source: enabled: true @@ -58,12 +150,22 @@ checksum: signs: - cmd: cosign - stdin: '{{ .Env.COSIGN_PWD }}' - args: ["sign-blob", "-key=/tmp/cosign.key", "-output=${signature}", "${artifact}"] + signature: "${artifact}.sigstore.json" + args: + - "sign-blob" + - "--bundle=${signature}" + - "${artifact}" + - "--yes" artifacts: all +publishers: +- name: Google Cloud Artifact Registry + ids: + - packages + cmd: ./scripts/package-upload.sh {{ abs .ArtifactPath }} {{ .Var.packageName }} {{ .Version }} {{ .Var.packageRelease }} + snapshot: - name_template: "{{ .Tag }}-next" + version_template: "{{ .Tag }}-next" release: # Repo in which the release will be created. @@ -76,7 +178,7 @@ release: # IDs of the archives to use. # Defaults to all. #ids: - # - foo + # - default # - bar # If set to true, will not auto-publish the release. @@ -98,25 +200,30 @@ release: header: | ## Official Release Artifacts - #### Linux - - - 📦 [step_linux_{{ .Version }}_amd64.tar.gz](https://dl.step.sm/gh-release/cli/gh-release-header/{{ .Tag }}/step_linux_{{ .Version }}_amd64.tar.gz) - - 📦 [step-cli_{{ .Env.DEB_VERSION }}_amd64.deb](https://dl.step.sm/gh-release/cli/gh-release-header/{{ .Tag }}/step-cli_{{ .Env.DEB_VERSION }}_amd64.deb) + Below are the most popular artifacts for `step` on each platform. - #### OSX Darwin + For packaged versions (Homebrew, Scoop, etc.), see our [installation docs](https://smallstep.com/docs/step-cli/installation). - - 📦 [step_darwin_{{ .Version }}_amd64.tar.gz](https://dl.step.sm/gh-release/cli/gh-release-header/{{ .Tag }}/step_darwin_{{ .Version }}_amd64.tar.gz) - - 📦 [step_darwin_{{ .Version }}_arm64.tar.gz](https://dl.step.sm/gh-release/cli/gh-release-header/{{ .Tag }}/step_darwin_{{ .Version }}_arm64.tar.gz) + #### Linux + - 📦 [step_linux_{{ .Version }}_amd64.tar.gz](https://dl.smallstep.com/gh-release/cli/gh-release-header/{{ .Tag }}/step_linux_{{ .Version }}_amd64.tar.gz) + - 📦 [step_linux_{{ .Version }}_arm64.tar.gz](https://dl.smallstep.com/gh-release/cli/gh-release-header/{{ .Tag }}/step_linux_{{ .Version }}_arm64.tar.gz) + - 📦 [step_linux_{{ .Version }}_armv7.tar.gz](https://dl.smallstep.com/gh-release/cli/gh-release-header/{{ .Tag }}/step_linux_{{ .Version }}_armv7.tar.gz) + - 📦 [step-cli_{{ replace .Version "-" "." }}-{{ .Var.packageRelease }}_amd64.deb](https://dl.smallstep.com/gh-release/cli/gh-release-header/{{ .Tag }}/step-cli_{{ replace .Version "-" "." }}-{{ .Var.packageRelease }}_amd64.deb) + - 📦 [step-cli-{{ replace .Version "-" "." }}-{{ .Var.packageRelease }}.x86_64.rpm](https://dl.smallstep.com/gh-release/cli/gh-release-header/{{ .Tag }}/step-cli-{{ replace .Version "-" "." }}-{{ .Var.packageRelease }}.x86_64.rpm) + - 📦 [step-cli_{{ replace .Version "-" "." }}-{{ .Var.packageRelease }}_arm64.deb](https://dl.smallstep.com/gh-release/cli/gh-release-header/{{ .Tag }}/step-cli_{{ replace .Version "-" "." }}-{{ .Var.packageRelease }}_arm64.deb) + - 📦 [step-cli-{{ replace .Version "-" "." }}-{{ .Var.packageRelease }}.aarch64.rpm](https://dl.smallstep.com/gh-release/cli/gh-release-header/{{ .Tag }}/step-cli-{{ replace .Version "-" "." }}-{{ .Var.packageRelease }}.aarch64.rpm) + - see `Assets` below for more builds - #### Windows + #### macOS Darwin - - 📦 [step_windows_{{ .Version }}_amd64.zip](https://dl.step.sm/gh-release/cli/gh-release-header/{{ .Tag }}/step_windows_{{ .Version }}_amd64.zip) + - 📦 [step_darwin_{{ .Version }}_amd64.tar.gz](https://dl.smallstep.com/gh-release/cli/gh-release-header/{{ .Tag }}/step_darwin_{{ .Version }}_amd64.tar.gz) + - 📦 [step_darwin_{{ .Version }}_arm64.tar.gz](https://dl.smallstep.com/gh-release/cli/gh-release-header/{{ .Tag }}/step_darwin_{{ .Version }}_arm64.tar.gz) - For more builds across platforms and architectures see the `Assets` section below. - And for packaged versions (Homebrew, Scoop, etc.), see our [installation docs](https://smallstep.com/docs/step-cli/installation). + #### Windows + - 📦 [step_windows_{{ .Version }}_amd64.zip](https://dl.smallstep.com/gh-release/cli/gh-release-header/{{ .Tag }}/step_windows_{{ .Version }}_amd64.zip) + - 📦 [step_windows_{{ .Version }}_arm64.zip](https://dl.smallstep.com/gh-release/cli/gh-release-header/{{ .Tag }}/step_windows_{{ .Version }}_arm64.zip) - Don't see the artifact you need? Open an issue [here](https://github.com/smallstep/cli/issues/new/choose). ## Signatures and Checksums @@ -126,8 +233,9 @@ release: ``` cosign verify-blob \ - -key https://raw.githubusercontent.com/smallstep/cli/master/cosign.pub \ - -signature ~/Downloads/step_darwin_{{ .Version }}_amd64.tar.gz.sig + --bundle ~/Downloads/step_darwin_{{ .Version }}_amd64.tar.gz.sigstore.json \ + --certificate-identity-regexp "https://github\.com/smallstep/workflows/.*" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ ~/Downloads/step_darwin_{{ .Version }}_amd64.tar.gz ``` @@ -140,13 +248,13 @@ release: Those were the changes on {{ .Tag }}! - Come join us on [Discord](https://discord.gg/X2RKGwEbV9) to ask questions, chat about PKI, or get a sneak peak at the freshest PKI memes. + Come join us on [Discord](https://discord.gg/X2RKGwEbV9) to ask questions, chat about PKI, or get a sneak peek at the freshest PKI memes. # You can disable this pipe in order to not upload any artifacts. # Defaults to false. #disable: true - # You can add extra pre-existing files to the release. + # You can add extra preexisting files to the release. # The filename on the release will be the last part of the path (base). If # another file with the same name exists, the latest one found will be used. # Defaults to empty. @@ -155,41 +263,226 @@ release: # - glob: ./glob/**/to/**/file/**/* # - glob: ./glob/foo/to/bar/file/foobar/override_from_previous -scoop: - # Template for the url which is determined by the given Token (github or gitlab) - # Default for github is "https://github.com///releases/download/{{ .Tag }}/{{ .ArtifactName }}" - # Default for gitlab is "https://gitlab.com///uploads/{{ .ArtifactUploadHash }}/{{ .ArtifactName }}" - # Default for gitea is "https://gitea.com///releases/download/{{ .Tag }}/{{ .ArtifactName }}" - url_template: "http://github.com/smallstep/cli/releases/download/{{ .Tag }}/{{ .ArtifactName }}" - - # Repository to push the app manifest to. - bucket: - owner: smallstep - name: scoop-bucket - - # Git author used to commit to the repository. - # Defaults are shown. - commit_author: - name: goreleaserbot - email: goreleaser@smallstep.com - - # The project name and current git tag are used in the format string. - commit_msg_template: "Scoop update for {{ .ProjectName }} version {{ .Tag }}" - - # Your app's homepage. - # Default is empty. - homepage: "https://smallstep.com/" +blobs: + - &S3_VERSIONED + provider: s3 + disable: 'false' + ids: + - default + bucket: '{{ .Env.AWS_S3_BUCKET }}' + region: '{{ .Env.AWS_S3_REGION }}' + directory: '/' + acl: public-read + extra_files: + - glob: ./dist/default_darwin_amd64_v1/bin/step + name_template: step_{{ .Version }}_darwin_amd64 + - glob: ./dist/default_darwin_arm64*/bin/step + name_template: step_{{ .Version }}_darwin_arm64 + - glob: ./dist/default_linux_amd64_v1/bin/step + name_template: step_{{ .Version }}_linux_amd64 + - glob: ./dist/default_linux_arm64*/bin/step + name_template: step_{{ .Version }}_linux_arm64 + - glob: ./dist/default_windows_amd64_v1/bin/step.exe + name_template: step_{{ .Version }}_windows_amd64.exe + - glob: ./dist/default_freebsd_*/bin/step + name_template: step_{{ .Version }}_freebsd_amd64 + extra_files_only: true + + # Unversioned (`latest`) copies of binaries. + # This section should only run on full releases (not prereleases). + - + << : *S3_VERSIONED + disable: '{{ if .Prerelease }}true{{ else }}false{{ end }}' + extra_files: + - glob: ./dist/default_darwin_amd64_v1/bin/step + name_template: step_latest_darwin_amd64 + - glob: ./dist/default_darwin_arm64*/bin/step + name_template: step_latest_darwin_arm64 + - glob: ./dist/default_linux_amd64_v1/bin/step + name_template: step_latest_linux_amd64 + - glob: ./dist/default_linux_arm64*/bin/step + name_template: step_latest_linux_arm64 + - glob: ./dist/default_windows_amd64_v1/bin/step.exe + name_template: step_latest_windows_amd64.exe + - glob: ./dist/default_freebsd_*/bin/step + name_template: step_latest_freebsd_amd64 + extra_files_only: true + +winget: + - + # IDs of the archives to use. + # Empty means all IDs. + ids: [ default ] + + # + # Default: ProjectName + # Templates: allowed + name: step + + # Publisher name. + # + # Templates: allowed + # Required. + publisher: Smallstep + + # Your app's description. + # + # Templates: allowed + # Required. + short_description: "A Swiss army knife for working with X.509 certificates, JWTs, etc." + + # License name. + # + # Templates: allowed + # Required. + license: "Apache-2.0" + + # Publisher URL. + # + # Templates: allowed + publisher_url: "https://smallstep.com" + + # Publisher support URL. + # + # Templates: allowed + publisher_support_url: "https://github.com/smallstep/certificates/discussions" + + # URL which is determined by the given Token (github, gitlab or gitea). + # + # Default depends on the client. + # Templates: allowed + url_template: "https://github.com/smallstep/cli/releases/download/{{ .Tag }}/{{ .ArtifactName }}" + + # Git author used to commit to the repository. + commit_author: + name: goreleaserbot + email: goreleaser@smallstep.com + + # The project name and current git tag are used in the format string. + # + # Templates: allowed + commit_msg_template: "{{ .PackageIdentifier }}: {{ .Tag }}" + + # Your app's homepage. + homepage: "https://github.com/smallstep/cli" + + # Your app's long description. + # + # Templates: allowed + description: | + step-cli lets you build, operate, and automate Public Key Infrastructure (PKI) + systems and workflows. It's a swiss army knife for authenticated encryption + (X.509, TLS), single sign-on (OAuth OIDC, SAML), multi-factor authentication + (OATH OTP, FIDO U2F), encryption mechanisms (JSON Web Encryption, NaCl), + and verifiable claims (JWT, SAML assertions). + + # License URL. + # + # Templates: allowed + license_url: "https://github.com/smallstep/cli/blob/master/LICENSE" + + # Release notes URL. + # + # Templates: allowed + release_notes_url: "https://github.com/smallstep/cli/releases/tag/{{ .Tag }}" + + # Create the PR - for testing + skip_upload: auto + + # Privacy URL. + # + # Templates: allowed + privacy_url: "https://smallstep.com/privacy-policy" + + # Tags. + tags: + - cli + - smallstep + - pki + - x509 + - certificates + - tls + - ssl + - jwt + - oauth + - security + - encryption + - cryptography + + # Repository to push the generated files to. + repository: + owner: smallstep + name: winget-pkgs + branch: "step-{{.Version}}" + + # Optionally a token can be provided, if it differs from the token + # provided to GoReleaser + # Templates: allowed + #token: "{{ .Env.GITHUB_PERSONAL_AUTH_TOKEN }}" + + # Sets up pull request creation instead of just pushing to the given branch. + # Make sure the 'branch' property is different from base before enabling + # it. + # + # Since: v1.17 + pull_request: + # Whether to enable it or not. + enabled: true + check_boxes: true + # Whether to open the PR as a draft or not. + # + # Default: false + # Since: v1.19 + # draft: true + + # Base can also be another repository, in which case the owner and name + # above will be used as HEAD, allowing cross-repository pull requests. + # + # Since: v1.19 + base: + owner: microsoft + name: winget-pkgs + branch: master + +scoops: + - + ids: [ default ] + name: step + # Template for the url which is determined by the given Token (github or gitlab) + # Default for github is "https://github.com///releases/download/{{ .Tag }}/{{ .ArtifactName }}" + # Default for gitlab is "https://gitlab.com///uploads/{{ .ArtifactUploadHash }}/{{ .ArtifactName }}" + # Default for gitea is "https://gitea.com///releases/download/{{ .Tag }}/{{ .ArtifactName }}" + url_template: "https://github.com/smallstep/cli/releases/download/{{ .Tag }}/{{ .ArtifactName }}" + + # Repository to push the app manifest to. + repository: + owner: smallstep + name: scoop-bucket + branch: main + + # Git author used to commit to the repository. + # Defaults are shown. + commit_author: + name: goreleaserbot + email: goreleaser@smallstep.com + + # The project name and current git tag are used in the format string. + commit_msg_template: "Scoop update for {{ .ProjectName }} version {{ .Tag }}" + + # Your app's homepage. + # Default is empty. + homepage: "https://smallstep.com/" - # Skip uploads for prerelease. - skip_upload: auto + # Skip uploads for prerelease. + skip_upload: auto - # Your app's description. - # Default is empty. - description: "Crypto toolkit for working with X.509, OAuth, JWT, OATH OTP, etc." + # Your app's description. + # Default is empty. + description: "Crypto toolkit for working with X.509, OAuth, JWT, OATH OTP, etc." - # Your app's license - # Default is empty. - license: "Apache-2.0" + # Your app's license + # Default is empty. + license: "Apache-2.0" #dockers: # - dockerfile: docker/Dockerfile diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f424ef9..0b208082 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,31 +6,656 @@ for all release notes for step cli and certificates. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). -## [Unreleased - 0.17.8] - DATE +## TEMPLATE -- do not alter or remove + +--- + +## [x.y.z] - aaaa-bb-cc + ### Added + +- Support for inspecting certificates with post-quantum algorithms ML-DSA and + SLH-DSA (smallstep/certinfo#69). + ### Changed + ### Deprecated + ### Removed + ### Fixed + ### Security +--- + +## [0.30.3] - 2026年06月09日 + +### Added + +- Add support for inspecting certificates with post-quantum algorithms ML-DSA and + SLH-DSA (smallstep/certinfo#69, smallstep/cli#1605). + +### Fixed + +- Fix file paths being misidentified as KMS URIs when the path didn't match a KMS + URI pattern; detection now uses an existence check instead (smallstep/cli#1604). +- Fix `step ssh proxycommand` hanging when the server closes the connection before + stdin is closed (smallstep/cli#1647). + + +## [0.30.2] - 2026年03月22日 + +- Update certificates to v0.30.2 + + +## [0.30.1] - 2026年03月18日 + +- Fix release issue + + +## [0.30.0] - 2026年03月18日 + +### Added + +- Allow using KMS URIs directly without the `--kms` flag for commands that use + the cryptoutils package (smallstep/cli#1560). + +### Changed + +- Expand `--kms` flag help text with detailed documentation for all supported + KMS types (YubiKey PIV, PKCS #11, TPM 2.0, Google Cloud KMS, AWS KMS, Azure + Key Vault) and usage examples (smallstep/cli#1550). +- Prefer `verification_uri_complete` over `verification_uri` in the OIDC + Device Authorization Flow when the IdP provides it, so users don't need to + manually enter a code (smallstep/cli#1430). +- Skip printing the user code during OIDC device authorization when the + complete verification URI already embeds it (smallstep/cli#1595). +- Suppress output messages for `step certificate needs-renewal` and `step ssh + needs-renewal` commands when certificates don't need renewal. Use the + `--verbose` flag to always show messages regardless of renewal status + (smallstep/cli#1548). + +### Fixed + +- Overwrite file when using --force with step crypto key format (smallstep/cli#1581) + + +## [0.29.0] - 2025年12月02日 + +### Added + +- Add PKIX fingerprint support for `step crypto key fingerprint` (smallstep/cli#1474) +- Add remote configuration of the provisioner GCP organization id (smallstep/cli#1490) + +### Changed + +- Do not create an identity token if it's not enabled (smallstep/cli#1495). +- Make --attestation-uri incompatible with --kms for `step ca certificate` (smallstep/cli#1516) + +## [0.28.7] - 2025年07月13日 + +### Added + +- Add support for specifying key usage, extended key usage, and basic constraints + in certificate requests (smallstep/crypto#767) +- Ensure HOMEDRIVE is used, on Windows, when locating SSH config file (smallstep/cli#1434) + +### Changed + +- Enable alternate SSH agents for `step ssh` on Windows (smallstep/cli#1428) +- Refactor CLI to enable testing via testscript (smallstep/cli#1426) + +### Fixed + +- Fix step ca token help text around validity period flags (smallstep/cli#1411) +- Fix some provisioner and policy prompt issues (smallstep/cli#1391) + * SCEP provisioners not detected in admin token flows. They now return an error, + similar to ACME provisioners, if selected. + * Invalid provisioner selection logic when managing provisioner policies. + The --provisioner flag was used to select a provisioner to authenticate + as well as the provisioner to manage policies for. + * Unexpected error messages showing "issuer" instead of "provisioner" flag. In certain + situations the CLI would return error messages indicating an issue with the --issuer + flag value, whereas it was actually supplied in the --provisioner flag. + + +## [0.28.6] - 2025年03月17日 + +- dependabot updates + +## [0.28.5] - 2025年03月05日 + +- v0.28.4 skipped due to broken CI + +### Added + +- Add the --set and --set-file flags to the step ca token command, allowing the user to set keys in the "user" claim in the resulting JWT. (smallstep/cli#1375) +- Support for downloading additional default settings when running 'step ssh config' (smallstep/cli#1377) + - 'min-password-length' and 'provisioner' + + +## [0.28.3] - 2025年02月20日 + +### Added + +- Add support for KMS in the ca renew and rekey commands (smallstep/cli#1353) + +### Fixed + +- Correctly handle redirect-url flag when bootstrapping (smallstep/cli#1350) + + +## [0.28.2] - 2024年11月20日 + +### Fixed + +- Broken release process + + +## [0.28.1] - 2024年11月19日 + +### Changed + +- Updated smallstep/certinfo package (smallstep/cli#1309) + + +## [0.28.0] - 2024年10月29日 + +### Added + +- disableSSHCAUser and disableSSHCAHost options to GCP provisioner create and update commands (smallstep/cli#1305) +- Support programmatically opening browser on Android devices (smallstep/cli#1301) + +### Fixed + +- Fix --context being ignored in commands that rely on certificates (smallstep/cli#1301) + + +## [0.27.5] - 2024年10月17日 + +### Added + +- Add `--remove-scope` flag to provisioner update command. Removes the given + scope, used to validate the scopes extension in an OpenID Connect token (smallstep/cli#1287) + + +## [0.27.4] - 2024年09月13日 + +### Added + +- Support for signing and publishing RPM and Deb packages to GCP Artifact Registry (smallstep/cli#1246) + +### Changed + +- Update Release download URLs for RPM and DEB packages with new file name formats (smallstep/cli#1256) + +### Fixed + +- Parse crlEntryExtensions in CRLs (smallstep/cli#1262) +- PowerShell 5.1 CLI crashes in Windows 11 (smallstep/cli#1257) + +### Notes + +- Skipping 0.27.3 to synchronize with smallstep/certificates + + +## [0.27.2] - 2024年07月18日 + +### Added + +- `console` flag to SSH commands (smallstep/cli#1238) +- Upload FreeBSD build to S3 (smallstep/cli#1239) + + +## [0.27.1] - 2024年07月11日 + +### Fixed + +- Broken release process + + +## [0.27.0] - 2024年07月11日 + +### Changed + +- Makefile: install to /usr/local/bin, not /usr/bin (smallstep/cli#1214) + +### Fixed + +- Set proper JOSE algorithm for Ed25519 keys (smallstep/cli#1208) +- Makefile: usage of install command line flags on MacOS (smallstep/cli#1212) +- Restore operation of '--bundle' flag in certificate inspect (smallstep/cli#1215) +- Fish completion (smallstep/cli#1222) +- Restore operation of inspect CSR from STDIN (smallstep/cli#1232) + +### Security + + +## [0.26.2] - 2024年06月13日 + +### Added + +- Options for auth-params and scopes to OIDC token generator (smallstep/cli#1154) +- --kty, --curve, and --size to ssh commands (login, certificate) (smallstep/cli#1156) +- Stdin input for SSH needs-renewal (smallstep/cli#1157) +- Allow users to define certificate comment in SSH agent (smallstep/cli#1158) +- Add OCSP and CRL support to certificate verify (smallstep/cli#1161) + + +## [0.26.1] - 2024年04月22日 + +### Added + +- Ability to output inspected CSR in PEM format (smallstep/cli#1153) + +### Fixed + +- Allow 'certificate inspect' to parse PEM files containig extraneous data (smallstep/cli#1153) + + +## [v0.26.0] - 2024年03月27日 + +### Added + +- Sending of (an automatically generated) request identifier in the X-Request-Id header (smallstep/cli#1120) + +### Changed + +- Upgrade certinfo (smallstep/cli#1129) +- Upgrade other dependencies + +### Fixed + +- OIDC flows failing using Chrome and other Chromium based browsers (smallstep/cli#1136) + +### Security + +- Upgrade to using cosign v2 for signing artifacts + +## [v0.25.2] - 2024年01月19日 + +### Added + +- Add support for Nebula certificates using ECDSA P-256 (smallstep/cli#1085) + +### Changed + +- Upgrade docker image using Debian to Bookworm (smallstep/cli#1080) +- Upgrade dependencies, including go-jose to v3 (smallstep/cli#1086) + +## [v0.25.1] - 2023年11月28日 + +### Added + +- Add `step crypto rand` command in (smallstep/cli#1054) +- Support for custom TPM device name in `--attestation-uri` flag in (smallstep/cli#1044) + +### Changed + +- Ignore BOM when reading files in (smallstep/cli#1045) +- Upgraded `truststore` to fix installing certificates on certain Linux systems in (smallstep/cli#1053) + +### Fixed + +- Scoop and WinGet releases +- Command completion for `zsh` in (smallstep/cli#1055) + +## [v0.25.0] - 2023年09月26日 + +### Added + +- Add support for provisioner claim `disableSmallstepExtensions` + (smallstep/cli#986) +- Add support for PowerShell plugins on Windows (smallstep/cli#992) +- Create API token using team slug (smallstep/cli#980) +- Detect OIDC tokens issued by Kubernetes (smallstep/cli#953) +- Add support for Smallstep Managed Endpoint X509 extension + (smallstep/cli#989) +- Support signing a certificate for a private key that can only be used for + encryption with the `--skip-csr-signature` flag in `step certificate create`. + Some KMSs restrict key usage to a single type of cryptographic operation. + This blocks RSA decryption keys from being used to sign a CSR for their public + key. Using the `--skip-csr-signature` flag, the public key is used directly + with a certificate template, removing the need for the CSR signature. +- Add all AWS identity document certificates (smallstep/certificates#1510) +- Add SCEP decrypter configuration flags (smallstep/cli#950) +- Add detection of OIDC tokens issued by Kubernetes (smallstep/cli#953) +- Add unversioned release artifacts to build (smallstep/cli#965) + +### Changed + +- Increase PBKDF2 iterations to 600k (smallstep/cli#949) +- `--kms` flag is no longer used for the CA (signing) key for +`step certificate create`. It was replaced by the `--ca-kms` flag +(smallstep/cli#942). +- Hide `step oauth command` on failure (smallstep/cli#993) + +### Fixed + +- Look for Windows plugins with executable extensions + (smallstep/certificates#976) +- Fix empty ca.json with invalid template data (smallstep/certificates#1501) +- Fix interactive prompt on docker builds (smallstep/cli#963) +- `step certificate fingerprint` correctly parse PEM files with non-PEM header + (smallstep/crypto#311) +- `step certificate format` correctly parse PEM files with non-PEM header + (smallstep/cli#1006) +- Fix TOFU flag in `ca provisioner update` (smallstep/cli#941) +- Make `--team` incompatible with `--fingerprint` and `--ca-url` in + `step ca bootstrap (smallstep/cli#1017) + +### Remove + +- Remove automatic creation of the step path (smallstep/certificates#991) + +## [v0.24.4] - 2023年05月11日 + +### Added + +- Documentation for fish completion (smallstep/cli#930) +- `--audience` flag to `step api token` (smallstep/cli#927) + +### Changed + +- Depend on [smallstep/go-attestation](https://github.com/smallstep/go-attestation) instead of [google/go-attestation](https://github.com/google/go-attestation) +- Implementation for parsing CRLs (smallstep/cli#926) + +## [v0.24.3] - 2023年04月14日 + +### Added + +- Storing of certificate chain for TPM keys in TPM storage (smallstep/cli#915) + +### Changed + +- The enrolment URL path used when enrolling with an attestation CA (smallstep/cli#915) + +### Fixed + +- Issue with CLI reference not showing curly braces correctly (smallstep/cli#916) +- Word wrapping for `step api token` example (smallstep/cli#917) + +## [v0.24.2] - 2023年04月14日 + +### Changed + +- Cross-compile Debian docker builds to improve release performance + (smallstep/cli#911). + +### Fixed + +- Fix encrypted PKCS#8 keys used on `step crypto key format` + (smallstep/crypto#216). + +## [v0.24.1] - 2023年04月12日 + +### Fixed + +- Upgrade certificates version (smallstep/cli#910). + +## [v0.24.0] - 2023年04月12日 + +### Added + +- Support for ACME device-attest-01 challenge with TPM 2.0 (smallstep/cli#712). +- Build and release cleanups (smallstep/cli#883, smallstep/cli#884, + smallstep/cli#888, and smallstep/cli#896). +- Release of the smallstep/step-cli:bullseye docker image with CGO and glibc + support (smallstep/cli#885). +- Support for reload using the HUP signal on the test command `step fileserver` + (smallstep/cli#891). +- Support for Azure sovereign clouds (smallstep/cli#872). + +### Fixed + +- Fix the `--insecure` flag when creating RSA keys of less than 2048 bits + (smallstep/cli#878). +- Fix docs for active revocation (smallstep/cli#889) +- Fix signing of X5C tokens with ECDSA P-384 and P-521 keys. +- Fix 404 links in docs (smallstep/cli#907). +- Linting and cleanup changes (smallstep/cli#904 and smallstep/cli#905). + +### Changed + +- Use key fingerprints by default for SSH certificates, and add `--certificate` + flag to print the certificate fingerprint (smallstep/cli#908). + +### Removed + +- Remove `--hugo` flag in `step help` command (smallstep/cli#898). + +## [v0.23.4] - 2023年03月09日 + +### Added + +- Support on `step ca token` for signing JWK, X5C and SSHPOP tokens using a KMS + (smallstep/cli#871). +- debian:bullseye base image (smallstep/cli#861) + +### Changed + +- `step certificate needs-renewal` will only check the leaf certificate by default. + To test the full certificate bundle use the `--bundle` flag. (smallstep/cli#873) +- Change how `step help --markdown` works: It now ouputs "REAME.mdx" instead of "index.md" + +## [v0.23.3] - 2023年03月01日 + +### Fixed + +- Prevent re-use of TCP connections between requests on `step oauth` (smallstep/cli#858). +- Upgrade certinfo with a fix for the YubiKey touch policy information (smallstep/cli#854). +- Upgrade Golang dependencies with reported issues. + +## [v0.23.2] - 2023年02月06日 + +### Added + +- Added support for extended SANs when creating CSRs (smallstep/crypto#168). +- Added check for empty DNS value in `step ca init` (smallstep/cli#815). + +### Changed + +- Improved prompts and error messages in `step ca init` (smallstep/cli#827), + (smallstep/cli#831), (smallstep/cli#839). +- Improved ACME device-attest-01 challenge validation logic (smallstep/cli#837). + +### Fixed + +- Fixed `step ca provisioner add` when CA is not online (smallstep/cli#833). + +## [v0.23.1] - 2023年01月10日 + +### Added + +- Add scope parameter in `step oauth` (smallstep/cli#816). + +### Changed + +- Check for remote configuration API before prompting for admin credentials + (smallstep/cli809). + +### Fixed + +- Generation of OTT when signing a CSR with URIs (smallstep/cli#799). +- CA certificates path for SLSE with + [smallstep/truststore/#16](https://github.com/smallstep/truststore/pull/16) + (smallstep/cli#818). + +## [v0.23.0] - 2022年11月11日 + +### Added + +- Added support for configuring ACME device-attest-01 challenges. +- Added support to disable ACME challenges and attestation formats. +- Added support for ACME device-attest-01 challenges with YubiKeys. +- Added support for SUSE13 and upwards for `step certificate install`. +- Added support for printing [Sigstore](https://www.sigstore.dev/) certificate + details to `step certificate inspect` +- Added the `--acme` flag to the `step ca init` command to create a default ACME + provisioner when initializing a CA. +- Added `--remote-management` flag to the `step ca init` command, which enables + Remote Management of the CA using the Admin API. +- Added `x5c` tokens using certificates and keys in a KMS. +- Added Window's CryptoAPI support on + [`step-kms-plugin`](https://github.com/smallstep/step-kms-plugin). +- Added `--admin-password-file` flag on admin flows. +- Added support for GitHub OAuth flows. + +### Changed + +- New OAuth success page with color. +- Added `x5c-roots` as alias for `x5c-root` flag. + +### Removed + +- Removed support for Google OOB. + +## [0.22.0] - 2022年08月25日 + +### Added + +- Initial support for `step` plugins. A plugin is an executable file named with + the format step-`name`-plugin, located in the `$PATH` or the + `$STEPPATH/plugins` directory. These plugins will be executed using `step + name`. +- Integration of [`step-kms-plugin`](https://github.com/smallstep/step-kms-plugin) + on `step certificate create` and `step certificate sign`. +- Add the certificate signature to `step ssh inspect` output. +- Add the `--mtls=false` flag to force the token authorization flow on `step ca + renew`. +- Add the `--set` and `--set-file` flag to `step certificate create` and +`step certificate sign` commands. + +### Changed + +- Support two latest versions of Go (1.18, 1.19) +- `step ca revoke ` requires either a base 10 serial number or a value +with a prefix indicating the appropriate base. + +## [0.21.0] - 2022年07月06日 + +### Added + +- Device Authorization Grant flow for input constrained devices needing OAuth +credentials. `--console-flow` flag in `step oauth` for selecting which +alternative OAuth flow to use. + +### Fixed + +- Added back --domain and --remove-domain flags to provisioner CRUD. + +### Removed + +- The `beta` prefix for remote provisioner and admin management. + +## [0.20.0] - 2022年05月26日 + +### Added + +- Add commands for managing certificate issuance policies on authority, provisioner and ACME account level. +- Admin API enabled functionality for `step beta ca provisioner` and `step beta ca admin`. + +### Deprecated + +- step beta ca provisioner [add|remove|update] -> functionality moved to step ca provisioner [add|remove|update] +- step beta ca admin [add|remove|update] -> functionality moved to step ca admin [add|remove|update] + +## [0.19.0] - 2022年04月19日 + +### Added + +- Add flags to include subscription and object ids in the Azure provisioner. +- Add support for certificate renewals after expiry using the `--allow-renewal-after-expiry` flag. +- Add `--x5c-insecure` flag. +- Add support for Azure `Managed Identity` tokens. +- Add `smtps` and `ldaps` as additional protocols supported by the `certificate inspect` command. +- Add `--sha1` flag to get `certificate fingerprint` using SHA-1 instead of the default SHA-256 algorithm. + +### Changed + +- Support two latest versions of Go (1.17, 1.18). + +### Deprecated + +- Go 1.16 support. + +### Removed + +### Fixed + +- Fix flags to add or remove options in AWS, Azure, and GCP provisioners. +- Fix admin credentials on RAs. + +### Security + +## [0.18.2] - 2022年03月01日 + +### Added + +- Add Solus OS support to truststore when used in `step ca bootstrap --install`. +- Add `step completion` command to print the shell completion script. + +### Changed + +- IPv6 addresses are normalized as IP addresses internally. +- When the `--context` flag is provided when initializing a CA, configuration and other files will be stored in a directory named after the value provided instead of being named after the first DNS name. + +### Fixed + +- IP SAN support when using `step ca sign` and an ACME provisioner (see [819](https://github.com/smallstep/certificates/discussions/819)). +- Offline mode no longer requires `--ca-url` to be set. +- Add missing `TemplateData` when signing x509 certificates in offline mode. +- Improved `needs-renewal` example help texts. +- Improved `step crl inspect` reason output. + +## [0.18.1] - 2022年02月03日 + +### Added + +- Add additional `emoji` and `base64-raw` encoding to the `--format` flag of `step certificate fingerprint`. +- Add `--format` flag to `step crypto key fingerprint`. +- Add `--format` flag to `step ssh fingerprint`. +- Add FreeBSD support to `step certificate install`. +- Add `step crl inspect` to inspect a certificate revocation list (CRL). +- Add `--auth-param` flag to `step oauth` for adding args to query. +- Add `--no-agent` flag to `step ssh certificate` to skip ssh-add. +- Add IP SANs support to `step ca certificate` when using an ACME provisioner. +- Add support for adding and updating Nebula provisioners. + +### Changed + +- Allow `step ssh login` and `step ssh logout` without positional arguments. +- Additional configuration options for SCEP provisioners. + ## [0.18.0] - 2021年11月17日 + ### Added + - Ability to use multiple certificate authority contexts without the need to change $STEPPATH. + ### Deprecated + - Support for go 1.15 ## [0.17.7] - 2021年10月20日 + ### Added + - gocritic linter - Allow to initialize step-ca config with Azure Key Vault using `step ca init --kms azurekms`. + ### Fixed + - gocritic warnings ### Security + ## [0.17.6] - 2021年10月01日 + ### Added + - Allow override of the listen address on OIDC flows when there is an existing value in provisioner configuration. - Add a way to set the redirect_uri in an OIDC flow. Allowing to get a @@ -41,41 +666,59 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [0.17.5] - 2021年09月28日 ## [0.17.4] - 2021年09月28日 + ### Fixed -- Bug in step ssh certificate --offline where password-file flag was always set + +- Bug in step ssh certificate --offline where password-file flag was always set to the value of provisioner-password-file flag. ## [0.17.3] - 2021年09月24日 + ### Added + - exit code '2' for file not exists scenarios in 'needs-renewal' commands - go 1.17 to github action test matrix -- non interactive provisioner password file flag in `step ca token --offline` +- non interactive provisioner password file flag in `step ca token --offline` + ### Changed + - Using go 1.17 to build - Have `--dns` behave as string slice flag in `step ca init` - The way CSR is created on `step ca certificate` with OIDC to better support of admins + ### Fixed + - Fix `make bootstrap` failing to get GOPATH and install `golangci-lint`. - ipv6 address error in multi-DNS csv `step ca init` + ### Security + - Use cosign to sign and upload signatures for multi-arch Docker container. - Debian checksum ## [0.17.2] - 2021年08月30日 + ### Security -- Sign over goreleaser github artifacts using cosign + +- Sign over goreleaser github artifacts using cosign ## [0.0.2] + ### Added -- `--bundle` flag to cert/inspect for inpecting all the full chain or bundle + +- `--bundle` flag to cert/inspect for inspecting all the full chain or bundle given a path. Default behavior is unchanged; only inspect the first (leaf) certificate. - distribution.md with documentation on how to create releases. -- travis build and upload artifacts to Github Releases on tagged pushes. +- travis build and upload artifacts to GitHub Releases on tagged pushes. - logging of invalid http requests to the oauth server + ### Changed + - default PEM format encryption alg AES128 -> AES256 ## [0.0.1] - 2018年08月07日 + ### Added + - Initial version of `step` diff --git a/Makefile b/Makefile index 0281e84d..4bb594ae 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,32 @@ +# Run `make bootstrap` to set up your local environment. +# To build using go, use `make build` +# For a binary that's in parity with how our CI system builds, +# run `make goreleaser` to build using GoReleaser Pro. + +# Variables: +# V=1 for verbose output. + +# the name of the executable +BINNAME?=step + +# the build output path +PREFIX?=bin + +# the install path +DESTDIR?=/usr/local/bin + +# GOOS_OVERRIDE="GOOS=linux GOARCH=arm GOARM=6" to change OS and arch +GOOS_OVERRIDE?= + +# CGO_OVERRIDE="CGO_ENABLED=1" to enable CGO +CGO_OVERRIDE?=CGO_ENABLED=0 + +# which build id in .goreleaser.yml to build +GORELEASER_BUILD_ID?=default + +# all go files +SRC=$(shell find . -type f -name '*.go' -or -name go.mod -or -name go.sum) + all: lint test build ci: test build @@ -8,17 +37,7 @@ ci: test build # Determine the type of `push` and `version` ################################################# -# If TRAVIS_TAG is set then we know this ref has been tagged. -ifdef TRAVIS_TAG -VERSION ?= $(TRAVIS_TAG) -NOT_RC := $(shell echo $(VERSION) | grep -v -e -rc) - ifeq ($(NOT_RC),) -PUSHTYPE := release-candidate - else -PUSHTYPE := release - endif -# GITHUB Actions -else ifdef GITHUB_REF +ifdef GITHUB_REF VERSION ?= $(shell echo $(GITHUB_REF) | sed 's/^refs\/tags\///') NOT_RC := $(shell echo $(VERSION) | grep -v -e -rc) ifeq ($(NOT_RC),) @@ -30,110 +49,187 @@ else VERSION ?= $(shell [ -d .git ] && git describe --tags --always --dirty="-dev") # If we are not in an active git dir then try reading the version from .VERSION. # .VERSION contains a slug populated by `git archive`. -VERSION := $(or $(VERSION),$(shell ./.version.sh .VERSION)) - ifeq ($(TRAVIS_BRANCH),master) -PUSHTYPE := master - else +VERSION := $(or $(VERSION),$(shell make/version.sh .VERSION)) PUSHTYPE := branch - endif endif VERSION := $(shell echo $(VERSION) | sed 's/^v//') -DEB_VERSION := $(shell echo $(VERSION) | sed 's/-/./') ifdef V -$(info TRAVIS_TAG is $(TRAVIS_TAG)) $(info GITHUB_REF is $(GITHUB_REF)) $(info VERSION is $(VERSION)) -$(info DEB_VERSION is $(DEB_VERSION)) $(info PUSHTYPE is $(PUSHTYPE)) endif -include make/common.mk -include make/docker.mk +DATE := $(shell date -u '+%Y-%m-%d %H:%M UTC') +ifdef DEBUG + LDFLAGS := -ldflags='-X "main.Version=$(VERSION)" -X "main.BuildTime=$(DATE)"' + GCFLAGS := -gcflags "all=-N -l" +else + LDFLAGS := -ldflags='-w -X "main.Version=$(VERSION)" -X "main.BuildTime=$(DATE)"' + GCFLAGS := +endif + +Q=$(if $V,,@) +OUTPUT_ROOT=output/ + +ifeq ($(OS),Windows_NT) + HOSTOS=Windows +else + HOSTOS=$(shell uname) +endif + +HOSTARCH=$(shell go env GOHOSTARCH) +ifeq ($(HOSTARCH),amd64) + HOSTARCH=x86_64 +endif + +GORELEASER_PRO_URL=https://github.com/goreleaser/goreleaser-pro/releases/latest/download/goreleaser-pro_$(HOSTOS)_$(HOSTARCH).tar.gz + +# Determine the hooks to skip. When using GoReleaser OSS with a Pro config, specifying "after" +# to be skipped results in an error. When using GoReleaser Pro running the "goreleaser-local" +# target both "post-hooks" and "after" are required to skip the upload to GCP. The logic below +# checks the GoReleaser binary to be Pro or not, and then sets the steps to skip accordingly. +# It's possible this is a GoReleaser bug for the case where a Pro config is used with GoReleaser +# OSS. +GORELEASER_OSS_SKIP=post-hooks +GORELEASER_PRO_SKIP=post-hooks,after +GORELEASER_SKIP=$(if $(filter true,$(shell goreleaser --version | grep -q goreleaser-pro && echo true || echo false)),$(GORELEASER_PRO_SKIP),$(GORELEASER_OSS_SKIP)) + +.PHONY: all + +######################################### +# Bootstrapping +######################################### +TMPDIR := $(shell mktemp -d) +bootstra%: GOPATH=$(shell go env GOPATH) +bootstra%: + $Q curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $$(go env GOPATH)/bin latest + $Q go install golang.org/x/vuln/cmd/govulncheck@latest + $Q go install gotest.tools/gotestsum@latest + $Q go install golang.org/x/tools/cmd/goimports@latest + @echo "Installing GoReleaser Pro into $(GOPATH)/bin" + $Q curl -o $(TMPDIR)/goreleaser.tar.gz -L $(GORELEASER_PRO_URL) + $Q ls $(TMPDIR) + $Q tar xvzf $(TMPDIR)/goreleaser.tar.gz -C $(TMPDIR) + $Q cp $(TMPDIR)/goreleaser $(GOPATH)/bin + +.PHONY: bootstra% + +######################################### +# Build +######################################### + +build: $(PREFIX)/$(BINNAME) + @echo "Build Complete!" + +$(PREFIX)/$(BINNAME): $(SRC) + $Q mkdir -p $(PREFIX) + $Q $(GOOS_OVERRIDE) $(CGO_OVERRIDE) go build \ + -v \ + -o $(PREFIX)/$(BINNAME) \ + $(GCFLAGS) $(LDFLAGS) \ + github.com/smallstep/cli/cmd/step + +goreleaser: + $Q mkdir -p $(PREFIX) + $Q $(GOOS_OVERRIDE) $(CGO_OVERRIDE) DEBUG=$(DEBUG) goreleaser build \ + --id $(GORELEASER_BUILD_ID) \ + --snapshot \ + --single-target \ + --clean \ + --skip=$(GORELEASER_SKIP) \ + --output $(PREFIX)/$(BINNAME) + +.PHONY: build goreleaser + ######################################### -# Debian +# Test ######################################### -changelog: - $Q echo "step-cli ($(DEB_VERSION)) unstable; urgency=medium"> debian/changelog - $Q echo>> debian/changelog - $Q echo " * See https://github.com/smallstep/cli/releases">> debian/changelog - $Q echo>> debian/changelog - $Q echo " -- Smallstep Labs, Inc. $(shell date -uR)">> debian/changelog +test: + $Q $(CGO_OVERRIDE) $(GOFLAGS) gotestsum -- -coverprofile=coverage.out -short -covermode=atomic ./... + +race: + $Q $(CGO_OVERRIDE) $(GOFLAGS) gotestsum -- -race ./... -debian: changelog - $Q set -e; mkdir -p $(RELEASE); \ - OUTPUT=../step-cli_*.deb; \ - rm -f $$OUTPUT; \ - dpkg-buildpackage -b -rfakeroot -us -uc && cp $$OUTPUT $(RELEASE)/ +.PHONY: test race -distclean: clean +######################################### +# Linting +######################################### + +fmt: + $Q goimports -local github.com/golangci/golangci-lint -l -w $(SRC) + +lint: golint govulncheck + +golint: SHELL:=/bin/bash +golint: + $Q LOG_LEVEL=error golangci-lint run --config <(curl -s https://raw.githubusercontent.com/smallstep/workflows/master/.golangci.yml) --timeout=30m + +govulncheck: + $Q govulncheck ./... + +.PHONY: fmt lint golint govulncheck + +######################################### +# Install +######################################### -.PHONY: changelog debian distclean +install: $(PREFIX)/$(BINNAME) + $Q mkdir -p $(DESTDIR)/ + $Q install $(PREFIX)/$(BINNAME) $(DESTDIR)/$(BINNAME) + +uninstall: + $Q rm -f $(DESTDIR)/$(BINNAME) + +.PHONY: install uninstall + +######################################### +# Clean +######################################### + +clean: + $Q rm -f $(PREFIX)/$(BINNAME) + $Q rm -rf dist + +.PHONY: clean ################################################# # Build statically compiled step binary for various operating systems ################################################# BINARY_OUTPUT=$(OUTPUT_ROOT)binary/ -RELEASE=./.releases define BUNDLE_MAKE # $(1) -- Go Operating System (e.g. linux, darwin, windows, etc.) # $(2) -- Go Architecture (e.g. amd64, arm, arm64, etc.) # $(3) -- Go ARM architectural family (e.g. 7, 8, etc.) # $(4) -- Parent directory for executables generated by 'make'. - $(q) GOOS_OVERRIDE='GOOS=$(1) GOARCH=$(2) GOARM=$(3)' PREFIX=$(4) make $(4)bin/step + $Q GOOS_OVERRIDE='GOOS=$(1) GOARCH=$(2) GOARM=$(3)' PREFIX=$(4) make $(4)/$(BINNAME) endef -binary-linux: - $(call BUNDLE_MAKE,linux,amd64,,$(BINARY_OUTPUT)linux/) +binary-linux-amd64: + $(call BUNDLE_MAKE,linux,amd64,,$(BINARY_OUTPUT)linux-amd64) binary-linux-arm64: - $(call BUNDLE_MAKE,linux,arm64,,$(BINARY_OUTPUT)linux.arm64/) + $(call BUNDLE_MAKE,linux,arm64,,$(BINARY_OUTPUT)linux-arm64) binary-linux-armv7: - $(call BUNDLE_MAKE,linux,arm,7,$(BINARY_OUTPUT)linux.armv7/) + $(call BUNDLE_MAKE,linux,arm,7,$(BINARY_OUTPUT)linux-armv7) binary-linux-mips: - $(call BUNDLE_MAKE,linux,mips,,$(BINARY_OUTPUT)linux.mips/) - -binary-darwin: - $(call BUNDLE_MAKE,darwin,amd64,,$(BINARY_OUTPUT)darwin/) + $(call BUNDLE_MAKE,linux,mips,,$(BINARY_OUTPUT)linux-mips) -binary-windows: - $(call BUNDLE_MAKE,windows,amd64,,$(BINARY_OUTPUT)windows/) +binary-darwin-amd64: + $(call BUNDLE_MAKE,darwin,amd64,,$(BINARY_OUTPUT)darwin-amd64) -define BUNDLE - # $(1) -- Format output as .ZIP archive, rather than .tar.gzip (for older windows architecture) - # $(2) -- Binary Output Dir Name - # $(3) -- Step Platform Name - # $(4) -- Step Binary Architecture - # $(5) -- Step Binary Name (For Windows Comaptibility) - $(q) ./make/bundle.sh $(1) "$(BINARY_OUTPUT)$(2)" "$(RELEASE)" "$(VERSION)" "$(3)" "$(4)" "$(5)" -endef - -bundle-linux: binary-linux binary-linux-arm64 binary-linux-armv7 binary-linux-mips - $(call BUNDLE,,linux,linux,amd64,step) - $(call BUNDLE,,linux.arm64,linux,arm64,step) - $(call BUNDLE,,linux.armv7,linux,armv7,step) - $(call BUNDLE,,linux.mips,linux,mips,step) - -bundle-darwin: binary-darwin - $(call BUNDLE,,darwin,darwin,amd64,step) - -bundle-windows: binary-windows - $(call BUNDLE,,windows,windows,amd64,step.exe) - $(call BUNDLE,--zip,windows,windows,amd64,step.exe) - -.PHONY: binary-linux binary-darwin binary-windows bundle-linux bundle-darwin bundle-windows - -################################################# -# Targets for creating step artifacts -################################################# +binary-darwin-arm64: + $(call BUNDLE_MAKE,darwin,arm64,,$(BINARY_OUTPUT)darwin-arm64) -docker-artifacts: docker-$(PUSHTYPE) +binary-windows-amd64: + $(call BUNDLE_MAKE,windows,amd64,,$(BINARY_OUTPUT)windows-amd64) -.PHONY: docker-artifacts +.PHONY: binary-linux-amd64 binary-linux-arm64 binary-linux-armv7 binary-linux-mips binary-darwin-amd64 binary-darwin-arm64 binary-windows-amd64 diff --git a/README.md b/README.md index bdf013a8..14550b58 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![GitHub release](https://img.shields.io/github/release/smallstep/cli.svg)](https://github.com/smallstep/cli/releases) [![Go Report Card](https://goreportcard.com/badge/github.com/smallstep/cli)](https://goreportcard.com/report/github.com/smallstep/cli) -[![Build Status](https://travis-ci.com/smallstep/cli.svg?branch=master)](https://travis-ci.com/smallstep/cli) +[![Build Status](https://github.com/smallstep/cli/actions/workflows/test.yml/badge.svg)](https://github.com/smallstep/cli) [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) [![CLA assistant](https://cla-assistant.io/readme/badge/smallstep/cli)](https://cla-assistant.io/smallstep/cli) @@ -10,10 +10,10 @@ [![Twitter followers](https://img.shields.io/twitter/follow/smallsteplabs.svg?label=Follow&style=social)](https://twitter.com/intent/follow?screen_name=smallsteplabs) `step` is an easy-to-use CLI tool for building, operating, and automating Public Key Infrastructure (PKI) systems and workflows. -It's the client counterpart to the [`step-ca` online Certificate Authority (CA)](https://github.com/smallstep/certificates). -You can use it for many common crypto and X.509 operations—independently, or with a CA. +It's also a client for the [`step-ca` online Certificate Authority (CA)](https://github.com/smallstep/certificates) server. +You can use it for many common crypto and X.509 operations—either independently, or with an online CA. -**Questions? Ask us on [GitHub Discussions](https://github.com/smallstep/certificates/discussions) or [Discord](https://bit.ly/step-discord).** +**Questions? Ask us on [GitHub Discussions](https://github.com/smallstep/certificates/discussions) or [Discord](https://u.step.sm/discord).** [Website](https://smallstep.com) | [Documentation](https://smallstep.com/docs/step-cli) | @@ -27,7 +27,7 @@ Step CLI's command groups illustrate its wide-ranging uses: - [`step certificate`](https://smallstep.com/docs/step-cli/reference/certificate/): Work with X.509 (TLS/HTTPS) certificates. - Create, revoke, validate, lint, and bundle X.509 certificates. - - Install (and remove) X.509 certificates into your system's (and brower's) trust store. + - Install (and remove) X.509 certificates into your system's (and browser's) trust store. - Validate certificate deployment and renewal status for automation - Create key pairs (RSA, ECDSA, EdDSA) and certificate signing requests (CSRs) - [Sign CSRs](https://smallstep.com/docs/step-cli/reference/certificate/sign/) @@ -37,7 +37,7 @@ Step CLI's command groups illustrate its wide-ranging uses: - [Inspect](https://smallstep.com/docs/step-cli/reference/certificate/inspect/) and [lint](https://smallstep.com/docs/step-cli/reference/certificate/lint/) certificates on disk or in use by a remote server - [Install root certificates](https://smallstep.com/docs/step-cli/reference/certificate/install/) so your CA is trusted by default (issue development certificates **that [work in browsers](https://smallstep.com/blog/step-v0-8-6-valid-HTTPS-certificates-for-dev-pre-prod.html)**) -- [`step ca`](https://smallstep.com/docs/step-cli/reference/ca/): Set up your own CA, or make requests of any ACMEv2 ([RFC8555](https://tools.ietf.org/html/rfc8555)) CA, including [`step-ca`](https://github.com/smallstep/certificates). ACME is the protocol used by Let's Encrypt to automate the issuance of HTTPS certificates. +- [`step ca`](https://smallstep.com/docs/step-cli/reference/ca/): Administer and use a [`step-ca`](https://github.com/smallstep/certificates) server, or any ACMEv2 ([RFC8555](https://tools.ietf.org/html/rfc8555)) compliant CA server. ACME is the protocol used by Let's Encrypt to automate the issuance of HTTPS certificates. - Initialize an X.509 and/or SSH CA in one command - [Authenticate and obtain a certificate](https://smallstep.com/docs/step-cli/reference/ca/certificate/) using any enrollment mechanism supported by [`step-ca`](https://github.com/smallstep/certificates) - Securely [distribute root certificates](https://smallstep.com/docs/step-cli/reference/ca/root/) and [bootstrap](https://smallstep.com/docs/step-cli/reference/ca/bootstrap/) PKI relying parties @@ -54,7 +54,7 @@ Step CLI's command groups illustrate its wide-ranging uses: - [Generate and verify](https://smallstep.com/docs/step-cli/reference/crypto/otp/) TOTP tokens for multi-factor authentication (MFA) - Work with [NaCl](https://nacl.cr.yp.to/)'s high-speed tools for encryption and signing - - [Apply key derivation functions](https://smallstep.com/docs/step-cli/reference/crypto/kdf/) (KDFs) and [verify passwords](https://smallstep.com/docs/step-cli/reference/crypto/kdf/compare/) using `scrypt`, `bcrypt`, and `argo2` + - [Apply key derivation functions](https://smallstep.com/docs/step-cli/reference/crypto/kdf/) (KDFs) and [verify passwords](https://smallstep.com/docs/step-cli/reference/crypto/kdf/compare/) using `scrypt`, `bcrypt`, and `argon2` - Generate and check [file hashes](https://smallstep.com/docs/step-cli/reference/crypto/hash/) - [`step oauth`](https://smallstep.com/docs/step-cli/reference/oauth/): Add an OAuth 2.0 single sign-on flow to any CLI application. @@ -78,10 +78,32 @@ Here's a quick example, combining `step oauth` and `step crypto` to get and veri ![Animated terminal showing step in practice](https://smallstep.com/images/blog/2018-08-07-unfurl.gif) +## Plugins + +A plugin is an executable file named using the format `step--plugin`. +Plugins must be available in your `$PATH` or in the `$STEPPATH/plugins` +directory (that's `$HOME/.step/plugins`, by default). + +When you run `step `, the CLI will automatically execute the corresponding +plugin, if found. + +Some known plugins include: + +- [**step-kms-plugin**](https://github.com/smallstep/step-kms-plugin): Manage +keys and certificates stored in a KMS, including HSMs, TPMs, YubiKeys, the macOS +Keychain, and cloud KMSs. +- [**step-kmsproxy-plugin**](https://github.com/orbit-online/step-kmsproxy-plugin): +Provides an HSM/KMS-backed authenticating proxy for mTLS services. Thanks to +[@andsens](https://github.com/andsens) for creating and maintaining this plugin! + +`step-kms-plugin` is also integrated directly into `step` to create +certificates, generate CSRs, sign tokens, and more using KMS-backed keys. + ## Community -* Connect with `step` users on [GitHub Discussions](https://github.com/smallstep/certificates/discussions) or [Discord](https://bit.ly/step-discord) +* Connect with `step` users on [GitHub Discussions](https://github.com/smallstep/certificates/discussions) or [Discord](https://u.step.sm/discord) * [Open an issue](https://github.com/smallstep/cli/issues/new/choose) and tell us what features you'd like to see +* [Contribute](./docs/CONTRIBUTING.md) to the `step` codebase * [Follow Smallstep on Twitter](https://twitter.com/smallsteplabs) ## Further Reading diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..3f7c4e1f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,8 @@ +We appreciate any effort to discover and disclose security vulnerabilities responsibly. + +If you would like to report a vulnerability in one of our projects, or have security concerns regarding Smallstep software, please email security@smallstep.com. + +In order for us to best respond to your report, please include any of the following: + * Steps to reproduce or proof-of-concept + * Any relevant tools, including versions used + * Tool output diff --git a/autocomplete/README.md b/autocomplete/README.md new file mode 100644 index 00000000..dcf3292a --- /dev/null +++ b/autocomplete/README.md @@ -0,0 +1,2 @@ +## Deprecated +The files in this folder are deprecated and will be removed in the future. The prefered way to access the completion scripts is through `step completion `. diff --git a/cmd/step/main.go b/cmd/step/main.go index 6fb2c820..8dc5e7b1 100644 --- a/cmd/step/main.go +++ b/cmd/step/main.go @@ -1,43 +1,10 @@ package main import ( - "fmt" - "log" - "math/rand" - "net/http" - "os" - "reflect" - "regexp" - "strings" - "time" - "github.com/smallstep/certificates/ca" - "github.com/smallstep/cli/command/version" - "github.com/smallstep/cli/usage" - "github.com/urfave/cli" - "go.step.sm/cli-utils/command" - "go.step.sm/cli-utils/errs" - "go.step.sm/cli-utils/step" - - // Enabled commands - _ "github.com/smallstep/cli/command/base64" - _ "github.com/smallstep/cli/command/beta" - _ "github.com/smallstep/cli/command/ca" - _ "github.com/smallstep/cli/command/certificate" - _ "github.com/smallstep/cli/command/context" - _ "github.com/smallstep/cli/command/crypto" - _ "github.com/smallstep/cli/command/fileserver" - _ "github.com/smallstep/cli/command/oauth" - _ "github.com/smallstep/cli/command/path" - _ "github.com/smallstep/cli/command/ssh" + "github.com/smallstep/cli-utils/step" - // Enabled cas interfaces. - _ "github.com/smallstep/certificates/cas/cloudcas" - _ "github.com/smallstep/certificates/cas/softcas" - _ "github.com/smallstep/certificates/cas/stepcas" - - // Profiling and debugging - _ "net/http/pprof" + "github.com/smallstep/cli/internal/cmd" ) // Version is set by an LDFLAG at build time representing the git tag or commit @@ -48,112 +15,15 @@ var Version = "N/A" // the time of build var BuildTime = "N/A" +// AppName is the name of the binary. Defaults to "step" if not set. +var AppName = "" + func init() { step.Set("Smallstep CLI", Version, BuildTime) ca.UserAgent = step.Version() - rand.Seed(time.Now().UnixNano()) + cmd.SetName(AppName) } func main() { - defer panicHandler() - // Override global framework components - cli.VersionPrinter = func(c *cli.Context) { - version.Command(c) - } - cli.AppHelpTemplate = usage.AppHelpTemplate - cli.SubcommandHelpTemplate = usage.SubcommandHelpTemplate - cli.CommandHelpTemplate = usage.CommandHelpTemplate - cli.HelpPrinter = usage.HelpPrinter - cli.FlagNamePrefixer = usage.FlagNamePrefixer - cli.FlagStringer = stringifyFlag - - // Configure cli app - app := cli.NewApp() - app.Name = "step" - app.HelpName = "step" - app.Usage = "plumbing for distributed systems" - app.Version = step.Version() - app.Commands = command.Retrieve() - app.Flags = append(app.Flags, cli.HelpFlag) - app.EnableBashCompletion = true - app.Copyright = "(c) 2018-2020 Smallstep Labs, Inc." - - // Flag of custom configuration flag - app.Flags = append(app.Flags, cli.StringFlag{ - Name: "config", - Usage: "path to the config file to use for CLI flags", - }) - - // All non-successful output should be written to stderr - app.Writer = os.Stdout - app.ErrWriter = os.Stderr - - // Start the golang debug logger if environment variable is set. - // See https://golang.org/pkg/net/http/pprof/ - debugProfAddr := os.Getenv("STEP_PROF_ADDR") - if debugProfAddr != "" { - go func() { - log.Println(http.ListenAndServe(debugProfAddr, nil)) - }() - } - - if err := app.Run(os.Args); err != nil { - if fe, ok := err.(errs.FriendlyError); ok { - if os.Getenv("STEPDEBUG") == "1" { - fmt.Fprintf(os.Stderr, "%+v\n\n%s", err, fe.Message()) - } else { - fmt.Fprintln(os.Stderr, fe.Message()) - fmt.Fprintln(os.Stderr, "Re-run with STEPDEBUG=1 for more info.") - } - } else { - if os.Getenv("STEPDEBUG") == "1" { - fmt.Fprintf(os.Stderr, "%+v\n", err) - } else { - fmt.Fprintln(os.Stderr, err) - } - } - // ignore exitAfterDefer error because the defer is required for recovery. - // nolint:gocritic - os.Exit(1) - } -} - -func panicHandler() { - if r := recover(); r != nil { - if os.Getenv("STEPDEBUG") == "1" { - fmt.Fprintf(os.Stderr, "%s\n", step.Version()) - fmt.Fprintf(os.Stderr, "Release Date: %s\n\n", step.ReleaseDate()) - panic(r) - } else { - fmt.Fprintln(os.Stderr, "Something unexpected happened.") - fmt.Fprintln(os.Stderr, "If you want to help us debug the problem, please run:") - fmt.Fprintf(os.Stderr, "STEPDEBUG=1 %s\n", strings.Join(os.Args, " ")) - fmt.Fprintln(os.Stderr, "and send the output to info@smallstep.com") - os.Exit(2) - } - } -} - -func flagValue(f cli.Flag) reflect.Value { - fv := reflect.ValueOf(f) - for fv.Kind() == reflect.Ptr { - fv = reflect.Indirect(fv) - } - return fv -} - -var placeholderString = regexp.MustCompile(`<.*?>`) - -func stringifyFlag(f cli.Flag) string { - fv := flagValue(f) - usg := fv.FieldByName("Usage").String() - placeholder := placeholderString.FindString(usg) - if placeholder == "" { - switch f.(type) { - case cli.BoolFlag, cli.BoolTFlag: - default: - placeholder = "" - } - } - return cli.FlagNamePrefixer(fv.FieldByName("Name").String(), placeholder) + "\t" + usg + cmd.Run() } diff --git a/command/README.md b/command/README.md index be2fa3ce..0e26c9fb 100644 --- a/command/README.md +++ b/command/README.md @@ -12,7 +12,7 @@ should exist within its own package if possible. For example, `version` and Any package used by a command but does not contain explicit business logic directly related to the command should exist in the top-level of this repository. For example, the `github.com/smallstep/cli/flags` and -`go.step.sm/cli-utils/errs` package are used by many different commands and +`github.com/smallstep/cli-utils/errs` package are used by many different commands and contain functionality for defining flags and creating/manipulating errors. ### Adding a Command @@ -68,10 +68,10 @@ and thus registered with the `smallstep/cli/command`. There are three packages which contain functionality to make writing commands easier: -- `github.com/smallstep/cli/usage` - `github.com/smallstep/cli/flags` - `github.com/smallstep/cli/prompts` -- `go.step.sm/cli-utils/errs` +- `github.com/smallstep/cli-utils/errs` +- `github.com/smallstep/cli-utils/usage` The usage package is used to extend the default documentation provided by `urfave/cli` by enabling us to document arguments, whether they are optional or @@ -79,8 +79,8 @@ required, and ensuring they're printed out as a part of the `step help` or `step -h` flow. If you need to add a different type of annotation to document an argument just add it to the `usage.Argument` struct! -When you add a flag, look into the pre-existing ones inside the `flags` -package. Could you use one of the pre-existing flags in order to reduce +When you add a flag, look into the preexisting ones inside the `flags` +package. Could you use one of the preexisting flags in order to reduce duplication? If not, make sure to add a flag so it could be used in future! The `errs` package contains functionality for defining and working with errors diff --git a/command/api/api.go b/command/api/api.go new file mode 100644 index 00000000..457cdeb1 --- /dev/null +++ b/command/api/api.go @@ -0,0 +1,25 @@ +package api + +import ( + "github.com/urfave/cli" + + "github.com/smallstep/cli-utils/command" + + "github.com/smallstep/cli/command/api/token" +) + +func init() { + cmd := cli.Command{ + Hidden: true, + Name: "api", + Usage: "authenticate to the Smallstep API", + UsageText: "**step api** [arguments] [global-flags] [subcommand-flags]", + Description: `**step api** provides commands for connecting to the Smallstep API. +`, + Subcommands: cli.Commands{ + token.Command(), + }, + } + + command.Register(cmd) +} diff --git a/command/api/token/create.go b/command/api/token/create.go new file mode 100644 index 00000000..dc270533 --- /dev/null +++ b/command/api/token/create.go @@ -0,0 +1,145 @@ +package token + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "path" + + "github.com/google/uuid" + "github.com/urfave/cli" + + "github.com/smallstep/cli-utils/errs" + "github.com/smallstep/cli-utils/ui" +) + +func createCommand() cli.Command { + return cli.Command{ + Name: "create", + Action: cli.ActionFunc(createAction), + Usage: "create a new token", + UsageText: `**step api token create** +[**--api-url**=] [**--audience**=] +`, + Flags: []cli.Flag{ + apiURLFlag, + audienceFlag, + }, + Description: `**step ca api token create** creates a new token for connecting to the Smallstep API. + +## POSITIONAL ARGUMENTS + + +: UUID or slug of the team the API token will be issued for. This is available in the Smallstep dashboard. + + +: File to read the certificate (PEM format). This certificate must be signed by a trusted root configured in the Smallstep dashboard. + + +: File to read the private key (PEM format). + +## EXAMPLES +Use a certificate to get a new API token: +''' +$ step api token create ff98be70-7cc3-4df5-a5db-37f5d3c96e23 internal.crt internal.key +''' + +Get a token using the team slug: +''' +$ step api token create teamfoo internal.crt internal.key +''' +`, + } +} + +type createTokenReq struct { + TeamID string `json:"teamID"` + TeamSlug string `json:"teamSlug"` + Bundle [][]byte `json:"bundle"` + Audience string `json:"audience,omitempty"` +} + +type createTokenResp struct { + Token string `json:"token"` + Message string `json:"message"` +} + +func createAction(ctx *cli.Context) (err error) { + if err := errs.NumberOfArguments(ctx, 3); err != nil { + return err + } + + args := ctx.Args() + + teamID := args.Get(0) + crtFile := args.Get(1) + keyFile := args.Get(2) + + parsedURL, err := url.Parse(ctx.String("api-url")) + if err != nil { + return err + } + parsedURL.Path = path.Join(parsedURL.Path, "api/auth") + apiURL := parsedURL.String() + + clientCert, err := tls.LoadX509KeyPair(crtFile, keyFile) + if err != nil { + return err + } + b := &bytes.Buffer{} + r := &createTokenReq{ + Bundle: clientCert.Certificate, + Audience: ctx.String("audience"), + } + if err := uuid.Validate(teamID); err != nil { + r.TeamSlug = teamID + } else { + r.TeamID = teamID + } + err = json.NewEncoder(b).Encode(r) + if err != nil { + return err + } + + post, err := http.NewRequest("POST", apiURL, b) + if err != nil { + return err + } + post.Header.Set("Content-Type", "application/json") + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.TLSClientConfig = &tls.Config{ + GetClientCertificate: func(*tls.CertificateRequestInfo) (*tls.Certificate, error) { + return &clientCert, nil + }, + MinVersion: tls.VersionTLS12, + } + client := http.Client{ + Transport: transport, + } + resp, err := client.Do(post) // #nosec G704 -- request depends on configuration + if err != nil { + return err + } + defer resp.Body.Close() + + respBody := &createTokenResp{} + if err := json.NewDecoder(resp.Body).Decode(respBody); err != nil { + return err + } + if resp.StatusCode != 201 { + if respBody.Message != "" { + return errors.New(respBody.Message) + } + return fmt.Errorf("failed to create token: %d", resp.StatusCode) + } + + // Print message to stderr for humans and token to stdout for scripts + ui.PrintSelected("Token successfully created", "") + fmt.Println(respBody.Token) + + return nil +} diff --git a/command/api/token/token.go b/command/api/token/token.go new file mode 100644 index 00000000..1925e63c --- /dev/null +++ b/command/api/token/token.go @@ -0,0 +1,33 @@ +package token + +import ( + "github.com/urfave/cli" +) + +// Command returns the token subcommand. +func Command() cli.Command { + return cli.Command{ + Name: "token", + Usage: "create tokens for connecting to the Smallstep API", + UsageText: "step api token [arguments] [global-flags] [subcommand-flags]", + Subcommands: cli.Commands{ + createCommand(), + }, + Description: `**step api token** command group provides commands for creating the +tokens required to connect to the Smallstep API. +`, + } +} + +// common flags +var ( + apiURLFlag = cli.StringFlag{ + Name: "api-url", + Usage: "URL where the Smallstep API can be found", + Value: "https://gateway.smallstep.com", + } + audienceFlag = cli.StringFlag{ + Name: "audience", + Usage: "Request a token for an audience other than the API Gateway", + } +) diff --git a/command/base64/base64.go b/command/base64/base64.go index 5bc8ddb2..32b8c1ea 100644 --- a/command/base64/base64.go +++ b/command/base64/base64.go @@ -8,9 +8,11 @@ import ( "strings" "github.com/pkg/errors" - "github.com/smallstep/cli/utils" "github.com/urfave/cli" - "go.step.sm/cli-utils/command" + + "github.com/smallstep/cli-utils/command" + + "github.com/smallstep/cli/utils" ) func init() { @@ -53,7 +55,7 @@ YWJjMTIzJCVeJiooKV8rLT1-Cg== ''' Decode an url encoded base64 string. The encoding type can be enforced -using the '-u' or '-r' flags, but it will be autodetected if they are not +using the '-u' or '-r' flags, but it will be auto-detected if they are not passed: ''' $ echo YWJjMTIzJCVeJiooKV8rLT1-Cg== | step base64 -d diff --git a/command/beta/beta.go b/command/beta/beta.go index dc2c827a..5b507378 100644 --- a/command/beta/beta.go +++ b/command/beta/beta.go @@ -1,9 +1,11 @@ package beta import ( - "github.com/smallstep/cli/command/ca" "github.com/urfave/cli" - "go.step.sm/cli-utils/command" + + "github.com/smallstep/cli-utils/command" + + "github.com/smallstep/cli/command/ca" ) // init creates and registers the ca command diff --git a/command/ca/acme/acme.go b/command/ca/acme/acme.go new file mode 100644 index 00000000..cfee4d7f --- /dev/null +++ b/command/ca/acme/acme.go @@ -0,0 +1,20 @@ +package acme + +import ( + "github.com/urfave/cli" + + "github.com/smallstep/cli/command/ca/acme/eab" +) + +// Command returns the acme subcommand. +func Command() cli.Command { + return cli.Command{ + Name: "acme", + Usage: "manage ACME settings", + UsageText: "**step ca acme** [arguments] [global-flags] [subcommand-flags]", + Description: `**step ca acme** command group provides facilities for managing ACME.`, + Subcommands: cli.Commands{ + eab.Command(), + }, + } +} diff --git a/command/ca/acme/eab/add.go b/command/ca/acme/eab/add.go new file mode 100644 index 00000000..2fe70c79 --- /dev/null +++ b/command/ca/acme/eab/add.go @@ -0,0 +1,95 @@ +package eab + +import ( + "fmt" + "os" + + "github.com/pkg/errors" + "github.com/urfave/cli" + + adminAPI "github.com/smallstep/certificates/authority/admin/api" + "github.com/smallstep/cli-utils/errs" + + "github.com/smallstep/cli/flags" + "github.com/smallstep/cli/utils/cautils" +) + +func addCommand() cli.Command { + return cli.Command{ + Name: "add", + Action: cli.ActionFunc(addAction), + Usage: "add ACME External Account Binding Key", + UsageText: `**step ca acme eab add** [] +[**--admin-cert**=] [**--admin-key**=] [**--admin-subject**=] +[**--admin-provisioner**=] [**--admin-password-file**=] +[**--ca-url**=] [**--root**=] [**--context**=]`, + Flags: []cli.Flag{ + flags.AdminCert, + flags.AdminKey, + flags.AdminSubject, + flags.AdminProvisioner, + flags.AdminPasswordFile, + flags.CaURL, + flags.Root, + flags.Context, + }, + Description: `**step ca acme eab add** adds ACME External Account Binding Key. + +## POSITIONAL ARGUMENTS + + +: Name of the provisioner to which the ACME EAB key will be added + + +: (Optional) reference (from external system) for the key that will be added + +## EXAMPLES + +Add an ACME External Account Binding Key without reference: +''' +$ step ca acme eab add my_acme_provisioner +''' + +Add an ACME External Account Binding Key with reference: +''' +$ step ca acme eab add my_acme_provisioner my_first_eab_key +'''`, + } +} + +func addAction(ctx *cli.Context) (err error) { + if err := errs.MinMaxNumberOfArguments(ctx, 1, 2); err != nil { + return err + } + + args := ctx.Args() + provisioner := args.Get(0) + + reference := "" + if ctx.NArg() == 2 { + reference = args.Get(1) + } + + client, err := cautils.NewAdminClient(ctx) + if err != nil { + return errors.Wrap(err, "error creating admin client") + } + + eak, err := client.CreateExternalAccountKey(provisioner, &adminAPI.CreateExternalAccountKeyRequest{ + Reference: reference, + }) + if err != nil { + return errors.Wrap(notImplemented(err), "error creating ACME EAB key") + } + + cliEAK := toCLI(ctx, client, eak) + + // TODO(hs): JSON output, so that executing this command can be more easily automated? + + out := os.Stdout + format := "%-36s%-28s%-48s%s\n" + fmt.Fprintf(out, format, "Key ID", "Provisioner", "Key (base64, raw url encoded)", "Reference") + fmt.Fprintf(out, format, cliEAK.id, cliEAK.provisioner, cliEAK.key, cliEAK.reference) + + return nil +} diff --git a/command/ca/acme/eab/eab.go b/command/ca/acme/eab/eab.go new file mode 100644 index 00000000..b989482b --- /dev/null +++ b/command/ca/acme/eab/eab.go @@ -0,0 +1,89 @@ +package eab + +import ( + "encoding/base64" + "fmt" + "html" + "strconv" + + "github.com/pkg/errors" + "github.com/urfave/cli" + + "github.com/smallstep/linkedca" + + "github.com/smallstep/certificates/authority/admin" + "github.com/smallstep/certificates/ca" +) + +type cliEAK struct { + id string + provisioner string + reference string + key string + createdAt string + boundAt string + account string +} + +func toCLI(_ *cli.Context, _ *ca.AdminClient, eak *linkedca.EABKey) *cliEAK { + boundAt := "" + if !eak.BoundAt.AsTime().IsZero() { + boundAt = eak.BoundAt.AsTime().Format("2006-01-02 15:04:05 -07:00") + } + return &cliEAK{ + id: eak.Id, + provisioner: eak.Provisioner, + reference: eak.Reference, + key: base64.RawURLEncoding.Strict().EncodeToString(eak.HmacKey), + createdAt: eak.CreatedAt.AsTime().Format("2006-01-02 15:04:05 -07:00"), + boundAt: boundAt, + account: eak.Account, + } +} + +// Command returns the eab subcommand. +func Command() cli.Command { + return cli.Command{ + Name: "eab", + Usage: "create and manage ACME External Account Binding Keys", + UsageText: "**step ca acme eab** [arguments] [global-flags] [subcommand-flags]", + Subcommands: cli.Commands{ + listCommand(), + addCommand(), + removeCommand(), + }, + Description: `**step ca acme eab** command group provides facilities for managing ACME + External Account Binding Keys. + +## EXAMPLES + +List the active ACME External Account Binding Keys: +''' +$ step ca acme eab list my_provisioner +''' + +Add an ACME External Account Binding Key: +''' +$ step ca acme eab add my_provisioner my_reference +''' + +Remove an ACME External Account Binding Key: +''' +$ step ca acme eab remove my_provisioner my_key_id +''' +`, + } +} + +// notImplemented checks if an error indicates that the operation is not implemented +// in the CA and adds additional information to the error if that's the case. Other +// types of errors pass through without changes. +func notImplemented(err error) error { + var adminErr *ca.AdminClientError + if errors.As(err, &adminErr) && adminErr.Type == admin.ErrorNotImplementedType.String() { + emoji := html.UnescapeString("&#"+strconv.Itoa(128640)+";") + " " + + html.UnescapeString("&#"+strconv.Itoa(129321)+";") + return fmt.Errorf("this functionality is currently only available in Certificate Manager: https://u.step.sm/cm %s", emoji) + } + return err +} diff --git a/command/ca/acme/eab/list.go b/command/ca/acme/eab/list.go new file mode 100644 index 00000000..51e1bfe5 --- /dev/null +++ b/command/ca/acme/eab/list.go @@ -0,0 +1,178 @@ +package eab + +import ( + "fmt" + "io" + "os" + "os/exec" + "strings" + + "github.com/pkg/errors" + "github.com/urfave/cli" + + "github.com/smallstep/certificates/ca" + "github.com/smallstep/cli-utils/errs" + + "github.com/smallstep/cli/flags" + "github.com/smallstep/cli/internal/cast" + "github.com/smallstep/cli/utils/cautils" +) + +func listCommand() cli.Command { + return cli.Command{ + Name: "list", + Action: cli.ActionFunc(listAction), + Usage: "list all ACME External Account Binding Keys", + UsageText: `**step ca acme eab list** [] +[**--limit**=] +[**--admin-cert**=] [**--admin-key**=] [**--admin-subject**=] +[**--admin-provisioner**=] [**--admin-password-file**=] +[**--ca-url**=] [**--root**=] [**--context**=]`, + Flags: []cli.Flag{ + flags.Limit, + flags.NoPager, + flags.AdminCert, + flags.AdminKey, + flags.AdminSubject, + flags.AdminProvisioner, + flags.AdminPasswordFile, + flags.CaURL, + flags.Root, + flags.Context, + }, + Description: `**step ca acme eab list** lists all ACME External Account Binding (EAB) Keys. + +Output will go to stdout by default. If many EAB keys are stored in the ACME provisioner, output will be sent to $PAGER (when set). + +## POSITIONAL ARGUMENTS + + +: Name of the provisioner to list ACME EAB keys for + + +: (Optional) reference (from external system) for the key to be listed + + +## EXAMPLES + +List all ACME External Account Binding Keys: +''' +$ step ca acme eab list my_acme_provisioner +''' + +Show ACME External Account Binding Key with specific reference: +''' +$ step ca acme eab list my_acme_provisioner my_reference +''' +`, + } +} + +func listAction(ctx *cli.Context) (err error) { + if err := errs.MinMaxNumberOfArguments(ctx, 1, 2); err != nil { + return err + } + + args := ctx.Args() + provisioner := args.Get(0) + + reference := "" + if ctx.NArg() == 2 { + reference = args.Get(1) + } + + client, err := cautils.NewAdminClient(ctx) + if err != nil { + return errors.Wrap(err, "error creating admin client") + } + + var out io.WriteCloser + var cmd *exec.Cmd + + usePager := true + if ctx.IsSet("no-pager") { + usePager = !ctx.Bool("no-pager") + } + + // the pipeSignalHandler goroutine ensures that the parent process is closed + // whenever one of its children is killed. + go pipeSignalHandler() + + // prepare the $PAGER command to run when not disabled and when available + pager := os.Getenv("PAGER") + if strings.ContainsAny(pager, " \t\n;&|") { + return errors.New("invalid PAGER environment value") + } + + if _, err := exec.LookPath(pager); err != nil { + return fmt.Errorf("invalid PAGER environment value: %w", err) + } + + if usePager && pager != "" { + cmd = exec.Command(pager) // #nosec G702 -- $PAGER is intended to be provided by users; basic validation applied + var err error + out, err = cmd.StdinPipe() + if err != nil { + return errors.Wrap(err, "error setting stdin") + } + defer out.Close() + cmd.Stdout = os.Stdout + } else { + out = os.Stdout + } + + // default to API paging per 100 entities + limit := uint(0) + if ctx.IsSet("limit") { + limit = ctx.Uint("limit") + } + + cursor := "" + format := "%-36s%-28s%-16s%-30s%-30s%-40s%s\n" + firstIteration := true + startedPager := false + + for { + options := []ca.AdminOption{ca.WithAdminCursor(cursor), ca.WithAdminLimit(cast.Int(limit))} + eaksResponse, err := client.GetExternalAccountKeysPaginate(provisioner, reference, options...) + if err != nil { + return errors.Wrap(notImplemented(err), "error retrieving ACME EAB keys") + } + if firstIteration && len(eaksResponse.EAKs) == 0 { + fmt.Printf("No ACME EAB keys stored for provisioner %s\n", provisioner) + break + } + if shouldStartPager := (firstIteration && cmd != nil); shouldStartPager { + if err := cmd.Start(); err != nil { + return errors.Wrap(err, "unable to start $PAGER") + } + startedPager = true + } + if firstIteration { + fmt.Fprintf(out, format, "Key ID", "Provisioner", "Key (masked)", "Created At", "Bound At", "Account", "Reference") + firstIteration = false + } + for _, k := range eaksResponse.EAKs { + cliEAK := toCLI(ctx, client, k) + _, err = fmt.Fprintf(out, format, cliEAK.id, cliEAK.provisioner, "*****", cliEAK.createdAt, cliEAK.boundAt, cliEAK.account, cliEAK.reference) + if err != nil { + return errors.Wrap(err, "error writing ACME EAB key to output") + } + } + if eaksResponse.NextCursor == "" { + break + } + cursor = eaksResponse.NextCursor + } + + // ensure closing the output when at the end of what needs to be output + out.Close() + + if startedPager { + if err := cmd.Wait(); err != nil { + return errors.Wrap(err, "error waiting for $PAGER") + } + } + + return nil +} diff --git a/command/ca/acme/eab/remove.go b/command/ca/acme/eab/remove.go new file mode 100644 index 00000000..857a4df1 --- /dev/null +++ b/command/ca/acme/eab/remove.go @@ -0,0 +1,76 @@ +package eab + +import ( + "fmt" + + "github.com/pkg/errors" + "github.com/urfave/cli" + + "github.com/smallstep/cli-utils/errs" + + "github.com/smallstep/cli/flags" + "github.com/smallstep/cli/utils/cautils" +) + +func removeCommand() cli.Command { + return cli.Command{ + Name: "remove", + Action: cli.ActionFunc(removeAction), + Usage: "remove an ACME EAB Key from the CA", + UsageText: `**step ca acme eab remove** +[**--admin-cert**=] [**--admin-key**=] [**--admin-subject**=] +[**--admin-provisioner**=] [**--admin-password-file**=] +[**--ca-url**=] [**--root**=] [**--context**=]`, + Flags: []cli.Flag{ + flags.AdminCert, + flags.AdminKey, + flags.AdminSubject, + flags.AdminProvisioner, + flags.AdminPasswordFile, + flags.CaURL, + flags.Root, + flags.Context, + }, + Description: `**step ca acme eab remove** removes an ACME EAB Key from the CA. + +## POSITIONAL ARGUMENTS + + +: Name of the provisioner to remove an ACME EAB key for + + +: The ACME EAB Key ID to remove + +## EXAMPLES + +Remove ACME EAB Key with Key ID "zFGdKC1sHmNf3Wsx3OujY808chxwEdmr" from my_acme_provisioner: +''' +$ step ca acme eab remove my_acme_provisioner zFGdKC1sHmNf3Wsx3OujY808chxwEdmr +''' +`, + } +} + +func removeAction(ctx *cli.Context) error { + if err := errs.NumberOfArguments(ctx, 2); err != nil { + return err + } + + args := ctx.Args() + provisioner := args.Get(0) + keyID := args.Get(1) + + client, err := cautils.NewAdminClient(ctx) + if err != nil { + return errors.Wrap(err, "error creating admin client") + } + + err = client.RemoveExternalAccountKey(provisioner, keyID) + if err != nil { + return errors.Wrap(notImplemented(err), "error removing ACME EAB key") + } + + fmt.Println("Key was deleted successfully!") + + return nil +} diff --git a/command/ca/acme/eab/sigchild.go b/command/ca/acme/eab/sigchild.go new file mode 100644 index 00000000..f2e55f12 --- /dev/null +++ b/command/ca/acme/eab/sigchild.go @@ -0,0 +1,19 @@ +//go:build !windows + +package eab + +import ( + "os" + "os/signal" + "syscall" +) + +func pipeSignalHandler() { + signals := make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGCHLD) + + for range signals { + signal.Stop(signals) + os.Exit(0) + } +} diff --git a/command/ca/acme/eab/sigchild_windows.go b/command/ca/acme/eab/sigchild_windows.go new file mode 100644 index 00000000..7c2edccc --- /dev/null +++ b/command/ca/acme/eab/sigchild_windows.go @@ -0,0 +1,5 @@ +//go:build windows + +package eab + +func pipeSignalHandler() {} diff --git a/command/ca/admin/add.go b/command/ca/admin/add.go index 8f8a3d87..5ccdee6e 100644 --- a/command/ca/admin/add.go +++ b/command/ca/admin/add.go @@ -5,12 +5,14 @@ import ( "os" "text/tabwriter" + "github.com/urfave/cli" + adminAPI "github.com/smallstep/certificates/authority/admin/api" + "github.com/smallstep/cli-utils/errs" + "github.com/smallstep/linkedca" + "github.com/smallstep/cli/flags" "github.com/smallstep/cli/utils/cautils" - "github.com/urfave/cli" - "go.step.sm/cli-utils/errs" - "go.step.sm/linkedca" ) func addCommand() cli.Command { @@ -18,10 +20,10 @@ func addCommand() cli.Command { Name: "add", Action: cli.ActionFunc(addAction), Usage: "add an admin to the CA configuration", - UsageText: `**step beta ca admin add** [**--super**] -[**--admin-cert**=] [**--admin-key**=] [**--admin-provisioner**=] -[**--admin-subject**=] [**--password-file**=] [**--ca-url**=] -[**--root**=] [**--context**=]`, + UsageText: `**step ca admin add** [**--super**] +[**--admin-cert**=] [**--admin-key**=] [**--admin-subject**=] +[**--admin-provisioner**=] [**--admin-password-file**=] +[**--ca-url**=] [**--root**=] [**--context**=]`, Flags: []cli.Flag{ cli.BoolFlag{ Name: "super", @@ -29,14 +31,14 @@ func addCommand() cli.Command { }, flags.AdminCert, flags.AdminKey, - flags.AdminProvisioner, flags.AdminSubject, - flags.PasswordFile, + flags.AdminProvisioner, + flags.AdminPasswordFile, flags.CaURL, flags.Root, flags.Context, }, - Description: `**step beta ca admin add** adds an admin to the CA configuration. + Description: `**step ca admin add** adds an admin to the CA configuration. ## POSITIONAL ARGUMENTS @@ -50,12 +52,12 @@ func addCommand() cli.Command { Add regular Admin: ''' -$ step beta ca admin add max@smallstep.com google +$ step ca admin add max@smallstep.com google ''' Add SuperAdmin: ''' -$ step beta ca admin add max@smallstep.com google --super +$ step ca admin add max@smallstep.com google --super ''' `, } diff --git a/command/ca/admin/admin.go b/command/ca/admin/admin.go index e9ae7757..73944753 100644 --- a/command/ca/admin/admin.go +++ b/command/ca/admin/admin.go @@ -4,11 +4,12 @@ import ( "errors" "fmt" - "github.com/smallstep/certificates/ca" - "github.com/smallstep/cli/ui" "github.com/urfave/cli" - "go.step.sm/cli-utils/errs" - "go.step.sm/linkedca" + + "github.com/smallstep/certificates/ca" + "github.com/smallstep/cli-utils/errs" + "github.com/smallstep/cli-utils/ui" + "github.com/smallstep/linkedca" ) // Command returns the jwk subcommand. @@ -16,7 +17,7 @@ func Command() cli.Command { return cli.Command{ Name: "admin", Usage: "create and manage the certificate authority admins", - UsageText: "**step beta ca admin** [arguments] [global-flags] [subcommand-flags]", + UsageText: "**step ca admin** [arguments] [global-flags] [subcommand-flags]", Subcommands: cli.Commands{ listCommand(), addCommand(), @@ -34,22 +35,22 @@ authority. List the active admins: ''' -$ step beta ca admin list +$ step ca admin list ''' Add an admin: ''' -$ step beta ca admin add max@smallstep.com my-jwk-provisioner --super +$ step ca admin add max@smallstep.com my-jwk-provisioner --super ''' Update an admin: ''' -$ step beta ca admin update max@smallstep.com --super=false +$ step ca admin update max@smallstep.com --super=false ''' Remove an admin: ''' -$ step beta ca admin remove max@smallstep.com +$ step ca admin remove max@smallstep.com '''`, } } @@ -70,7 +71,7 @@ type cliAdmin struct { ProvisionerType string } -func toCLI(ctx *cli.Context, client *ca.AdminClient, adm *linkedca.Admin) (*cliAdmin, error) { +func toCLI(_ *cli.Context, client *ca.AdminClient, adm *linkedca.Admin) (*cliAdmin, error) { p, err := client.GetProvisioner(ca.WithProvisionerID(adm.ProvisionerId)) if err != nil { return nil, err diff --git a/command/ca/admin/list.go b/command/ca/admin/list.go index a7b1e9c6..c3e33a29 100644 --- a/command/ca/admin/list.go +++ b/command/ca/admin/list.go @@ -5,11 +5,13 @@ import ( "os" "text/tabwriter" + "github.com/urfave/cli" + + "github.com/smallstep/cli-utils/errs" + "github.com/smallstep/linkedca" + "github.com/smallstep/cli/flags" "github.com/smallstep/cli/utils/cautils" - "github.com/urfave/cli" - "go.step.sm/cli-utils/errs" - "go.step.sm/linkedca" ) func listCommand() cli.Command { @@ -17,10 +19,10 @@ func listCommand() cli.Command { Name: "list", Action: cli.ActionFunc(listAction), Usage: "list all admins in the CA configuration", - UsageText: `**step beta ca admin list** [**--super**] [**--provisioner**=] -[**--admin-cert**=] [**--admin-key**=] [**--admin-provisioner**=] -[**--admin-subject**=] [**--password-file**=] [**--ca-url**=] -[**--root**=] [**--context**=]`, + UsageText: `**step ca admin list** [**--super**] [**--provisioner**=] +[**--admin-cert**=] [**--admin-key**=] [**--admin-subject**=] +[**--admin-provisioner**=] [**--admin-password-file**=] +[**--ca-url**=] [**--root**=] [**--context**=]`, Flags: []cli.Flag{ cli.BoolFlag{ Name: "super", @@ -29,40 +31,40 @@ func listCommand() cli.Command { provisionerFilterFlag, flags.AdminCert, flags.AdminKey, - flags.AdminProvisioner, flags.AdminSubject, - flags.PasswordFile, + flags.AdminProvisioner, + flags.AdminPasswordFile, flags.CaURL, flags.Root, flags.Context, }, - Description: `**step beta ca admin list** lists all admins in the CA configuration. + Description: `**step ca admin list** lists all admins in the CA configuration. ## EXAMPLES List all admins: ''' -$ step beta ca admin list +$ step ca admin list ''' List only super-admins: ''' -$ step beta ca admin list --super +$ step ca admin list --super ''' List only admins without super-admin privileges: ''' -$ step beta ca admin list --super=false +$ step ca admin list --super=false ''' List all admins associated with a given provisioner: ''' -$ step beta ca admin list --provisioner admin-jwk +$ step ca admin list --provisioner admin-jwk ''' List only super-admins associated with a given provisioner: ''' -$ step beta ca admin list --super --provisioner admin-jwk +$ step ca admin list --super --provisioner admin-jwk ''' `, } @@ -97,7 +99,7 @@ func listAction(ctx *cli.Context) (err error) { if isNotSuperAdmin && a.Type == linkedca.Admin_SUPER_ADMIN { return false } - if len(provName)> 0 && a.ProvisionerName != provName { + if provName != "" && a.ProvisionerName != provName { return false } return true diff --git a/command/ca/admin/remove.go b/command/ca/admin/remove.go index 2e40c7a3..40caf5d2 100644 --- a/command/ca/admin/remove.go +++ b/command/ca/admin/remove.go @@ -1,10 +1,12 @@ package admin import ( + "github.com/urfave/cli" + + "github.com/smallstep/cli-utils/errs" + "github.com/smallstep/cli/flags" "github.com/smallstep/cli/utils/cautils" - "github.com/urfave/cli" - "go.step.sm/cli-utils/errs" ) func removeCommand() cli.Command { @@ -12,22 +14,22 @@ func removeCommand() cli.Command { Name: "remove", Action: cli.ActionFunc(removeAction), Usage: "remove an admin from the CA configuration", - UsageText: `**step beta ca admin remove** [**--provisioner**=] -[**--admin-cert**=] [**--admin-key**=] [**--admin-provisioner**=] -[**--admin-subject**=] [**--password-file**=] [**--ca-url**=] -[**--root**=] [**--context**=]`, + UsageText: `**step ca admin remove** [**--provisioner**=] +[**--admin-cert**=] [**--admin-key**=] [**--admin-subject**=] +[**--admin-provisioner**=] [**--admin-password-file**=] +[**--ca-url**=] [**--root**=] [**--context**=]`, Flags: []cli.Flag{ provisionerFilterFlag, flags.AdminCert, flags.AdminKey, - flags.AdminProvisioner, flags.AdminSubject, - flags.PasswordFile, + flags.AdminProvisioner, + flags.AdminPasswordFile, flags.CaURL, flags.Root, flags.Context, }, - Description: `**step beta ca admin remove** removes an admin from the CA configuration. + Description: `**step ca admin remove** removes an admin from the CA configuration. ## POSITIONAL ARGUMENTS @@ -38,12 +40,12 @@ func removeCommand() cli.Command { Remove an admin: ''' -$ step beta ca admin remove max@smallstep.com +$ step ca admin remove max@smallstep.com ''' Remove an admin with additional filtering by provisioner: ''' -$ step beta ca admin remove max@smallstep.com --provisioner admin-jwk +$ step ca admin remove max@smallstep.com --provisioner admin-jwk ''' `, } diff --git a/command/ca/admin/update.go b/command/ca/admin/update.go index ab65f901..e01d8f2e 100644 --- a/command/ca/admin/update.go +++ b/command/ca/admin/update.go @@ -5,12 +5,14 @@ import ( "os" "text/tabwriter" + "github.com/urfave/cli" + adminAPI "github.com/smallstep/certificates/authority/admin/api" + "github.com/smallstep/cli-utils/errs" + "github.com/smallstep/linkedca" + "github.com/smallstep/cli/flags" "github.com/smallstep/cli/utils/cautils" - "github.com/urfave/cli" - "go.step.sm/cli-utils/errs" - "go.step.sm/linkedca" ) func updateCommand() cli.Command { @@ -18,10 +20,10 @@ func updateCommand() cli.Command { Name: "update", Action: cli.ActionFunc(updateAction), Usage: "update an admin", - UsageText: `**step beta ca admin update** [**--super**] [**--provisioner**=] -[**--admin-cert**=] [**--admin-key**=] [**--admin-provisioner**=] -[**--admin-subject**=] [**--password-file**=] [**--ca-url**=] -[**--root**=] [**--context**=]`, + UsageText: `**step ca admin update** [**--super**] [**--provisioner**=] +[**--admin-cert**=] [**--admin-key**=] [**--admin-subject**=] +[**--admin-provisioner**=] [**--admin-password-file**=] +[**--ca-url**=] [**--root**=] [**--context**=]`, Flags: []cli.Flag{ cli.BoolFlag{ Name: "super", @@ -30,14 +32,14 @@ func updateCommand() cli.Command { provisionerFilterFlag, flags.AdminCert, flags.AdminKey, - flags.AdminProvisioner, flags.AdminSubject, - flags.PasswordFile, + flags.AdminProvisioner, + flags.AdminPasswordFile, flags.CaURL, flags.Root, flags.Context, }, - Description: `**step beta ca admin update** updates an admin. + Description: `**step ca admin update** updates an admin. ## POSITIONAL ARGUMENTS @@ -48,17 +50,17 @@ func updateCommand() cli.Command { Add super-admin privileges to an admin: ''' -$ step beta ca admin update max@smallstep.com --super +$ step ca admin update max@smallstep.com --super ''' Specify admin by provisioner: ''' -$ step beta ca admin update max@smallstep.com --super --provisioner devops-jwk +$ step ca admin update max@smallstep.com --super --provisioner devops-jwk ''' Remove super-admin privileges from an admin: ''' -$ step beta ca admin update max@smallstep.com --super=false +$ step ca admin update max@smallstep.com --super=false ''' `, } diff --git a/command/ca/bootstrap.go b/command/ca/bootstrap.go index 6a057335..1d28f3b9 100644 --- a/command/ca/bootstrap.go +++ b/command/ca/bootstrap.go @@ -3,11 +3,13 @@ package ca import ( "strings" + "github.com/urfave/cli" + + "github.com/smallstep/cli-utils/command" + "github.com/smallstep/cli-utils/errs" + "github.com/smallstep/cli/flags" "github.com/smallstep/cli/utils/cautils" - "github.com/urfave/cli" - "go.step.sm/cli-utils/command" - "go.step.sm/cli-utils/errs" ) func bootstrapCommand() cli.Command { @@ -34,13 +36,13 @@ After the bootstrap, ca commands do not need to specify the flags Bootstrap using the CA url and a fingerprint: ''' -$ step ca bootstrap --ca-url https://ca.example.org \ +$ step ca bootstrap --ca-url https://ca.example.com \ --fingerprint d9d0978692f1c7cc791f5c343ce98771900721405e834cd27b9502cc719f5097 ''' Bootstrap and install the root certificate ''' -$ step ca bootstrap --ca-url https://ca.example.org \ +$ step ca bootstrap --ca-url https://ca.example.com \ --fingerprint d9d0978692f1c7cc791f5c343ce98771900721405e834cd27b9502cc719f5097 \ --install ''' @@ -53,19 +55,19 @@ $ step ca bootstrap --team superteam To use team IDs in your own environment, you'll need an HTTP(S) server serving a JSON file: ''' -{"url":"https://ca.example.org","fingerprint":"d9d0978692f1c7cc791f5c343ce98771900721405e834cd27b9502cc719f5097"} +{"url":"https://ca.example.com","fingerprint":"d9d0978692f1c7cc791f5c343ce98771900721405e834cd27b9502cc719f5097"} ''' -Then, this command will look for the file at https://config.example.org/superteam: +Then, this command will look for the file at https://config.example.com/superteam: ''' -$ step ca bootstrap --team superteam --team-url https://config.example.org/ +$ step ca bootstrap --team superteam --team-url https://config.example.com/ '''`, Flags: []cli.Flag{ flags.CaURL, fingerprintFlag, cli.BoolFlag{ Name: "install", - Usage: "Install the root certificate into the system truststore.", + Usage: "Install the root certificate into the system's default trust store.", }, flags.Team, flags.TeamAuthority, @@ -90,6 +92,10 @@ func bootstrapAction(ctx *cli.Context) error { teamAuthority := ctx.String("team-authority") switch { + case team != "" && caURL != "": + return errs.IncompatibleFlagWithFlag(ctx, "team", "ca-url") + case team != "" && fingerprint != "": + return errs.IncompatibleFlagWithFlag(ctx, "team", "fingerprint") case team != "" && teamAuthority != "": return cautils.BootstrapTeamAuthority(ctx, team, teamAuthority) case team != "": diff --git a/command/ca/ca.go b/command/ca/ca.go index f14e2445..bd5e68c5 100644 --- a/command/ca/ca.go +++ b/command/ca/ca.go @@ -1,11 +1,14 @@ package ca import ( + "github.com/urfave/cli" + + "github.com/smallstep/cli-utils/command" + + "github.com/smallstep/cli/command/ca/acme" "github.com/smallstep/cli/command/ca/admin" + "github.com/smallstep/cli/command/ca/policy" "github.com/smallstep/cli/command/ca/provisioner" - "github.com/smallstep/cli/command/ca/provisionerbeta" - "github.com/urfave/cli" - "go.step.sm/cli-utils/command" ) // init creates and registers the ca command @@ -73,9 +76,12 @@ $ step ca renew internal.crt internal.key \ revokeCertificateCommand(), provisioner.Command(), signCertificateCommand(), - rootComand(), + rootCommand(), rootsCommand(), federationCommand(), + acme.Command(), + policy.Command(), + admin.Command(), }, } @@ -133,11 +139,6 @@ location being served by an existing fileserver in order to respond to ACME challenge validation requests.`, } - consoleFlag = cli.BoolFlag{ - Name: "console", - Usage: "Complete the flow while remaining inside the terminal", - } - fingerprintFlag = cli.StringFlag{ Name: "fingerprint", Usage: "The of the targeted root certificate.", @@ -163,8 +164,7 @@ func BetaCommand() cli.Command { Description: `**step beta ca** enables beta access to new step-ca APIs. These commands may change, disappear, or be promoted to a different subcommand in the future.`, Subcommands: cli.Commands{ - admin.Command(), - provisionerbeta.Command(), + acme.Command(), }, } } diff --git a/command/ca/certificate.go b/command/ca/certificate.go index 3084c144..74ea4db9 100644 --- a/command/ca/certificate.go +++ b/command/ca/certificate.go @@ -1,17 +1,21 @@ package ca import ( + "path/filepath" "strings" "github.com/pkg/errors" - "github.com/smallstep/cli/crypto/pemutil" + "github.com/urfave/cli" + + "github.com/smallstep/cli-utils/command" + "github.com/smallstep/cli-utils/errs" + "github.com/smallstep/cli-utils/step" + "github.com/smallstep/cli-utils/ui" + "go.step.sm/crypto/pemutil" + "github.com/smallstep/cli/flags" "github.com/smallstep/cli/token" - "github.com/smallstep/cli/ui" "github.com/smallstep/cli/utils/cautils" - "github.com/urfave/cli" - "go.step.sm/cli-utils/command" - "go.step.sm/cli-utils/errs" ) func certificateCommand() cli.Command { @@ -20,13 +24,15 @@ func certificateCommand() cli.Command { Action: command.ActionFunc(certificateAction), Usage: "generate a new private key and certificate signed by the root certificate", UsageText: `**step ca certificate** -[**--token**=] [**--issuer**=] [**--not-before**=] -[**--not-after**=] [**--san**=] [**--set**=] -[**--set-file**=] [**--acme**=] [**--standalone**] [**--webroot**=] -[**--contact**=] [**--http-listen**=
] [**--bundle**] +[**--token**=] [**--issuer**=] [**--provisioner-password-file**=] +[**--not-before**=] [**--not-after**=] +[**--san**=] [**--set**=] [**--set-file**=] +[**--acme**=] [**--standalone**] [**--webroot**=] +[**--contact**=] [**--http-listen**=
] [**--kty**=] [**--curve**=] [**--size**=] [**--console**] [**--x5c-cert**=] [**--x5c-key**=] [**--k8ssa-token-path**=] -[**--ca-url**=] [**--root**=] [**--context**=]`, +[**--offline**] [**--password-file**] [**--ca-url**=] [**--root**=] +[**--context**=]`, Description: `**step ca certificate** command generates a new certificate pair ## POSITIONAL ARGUMENTS @@ -73,6 +79,16 @@ files, certificates, and keys created with **step ca init**: $ step ca certificate --offline internal.example.com internal.crt internal.key ''' +Request a new certificate using the offline mode with additional flags to avoid +console prompts: +''' +$ step ca certificate --offline \ + --password-file ./pass.txt \ + --provisioner foo \ + --provisioner-password-file ./provisioner-pass.txt \ + internal.example.com internal.crt internal.key +''' + Request a new certificate using an OIDC provisioner: ''' $ step ca certificate --token $(step oauth --oidc --bare) joe@example.com joe.crt joe.key @@ -93,6 +109,13 @@ Request a new certificate with an X5C provisioner: $ step ca certificate foo.internal foo.crt foo.key --x5c-cert x5c.cert --x5c-key x5c.key ''' +Request a new certificate with an X5C provisioner using a certificate and private key stored on a YubiKey: +''' +$ step ca certificate joe@example.com joe.crt joe.key \ + --x5c-cert yubikey:slot-id=9a \ + --x5c-key 'yubikey:slot-id=9a?pin=value=123456' +''' + **Certificate Templates** - With a provisioner configured with a custom template we can use the **--set** flag to pass user variables: ''' @@ -141,6 +164,24 @@ $ step ca certificate foo.internal foo.crt foo.key \ that should be authorized. Use the '--san' flag multiple times to configure multiple SANs. The '--san' flag and the '--token' flag are mutually exclusive.`, }, + cli.StringFlag{ + Name: "attestation-ca-url", + Usage: "The base url of the Attestation CA to use", + }, + cli.StringFlag{ + Name: "attestation-ca-root", + Usage: "The path to the PEM with trusted roots when connecting to the Attestation CA", + }, + cli.BoolFlag{ + Name: "attestation-ca-insecure", + Usage: "Disables TLS server validation when connecting to the Attestation CA", + Hidden: true, + }, + cli.StringFlag{ + Name: "tpm-storage-directory", + Usage: "The directory where TPM keys and certificates will be stored", + Value: filepath.Join(step.Path(), "tpm"), + }, flags.TemplateSet, flags.TemplateSetFile, flags.CaConfig, @@ -155,11 +196,17 @@ multiple SANs. The '--san' flag and the '--token' flag are mutually exclusive.`, flags.Size, flags.NotAfter, flags.NotBefore, + flags.AttestationURI, flags.Force, flags.Offline, - consoleFlag, + flags.PasswordFile, + flags.Console, + flags.KMSUri, flags.X5cCert, flags.X5cKey, + flags.X5cChain, + flags.NebulaCert, + flags.NebulaKey, acmeFlag, acmeStandaloneFlag, acmeWebrootFlag, @@ -171,10 +218,15 @@ multiple SANs. The '--san' flag and the '--token' flag are mutually exclusive.`, } func certificateAction(ctx *cli.Context) error { - if err := errs.NumberOfArguments(ctx, 3); err != nil { + if err := errs.MinMaxNumberOfArguments(ctx, 2, 3); err != nil { return err } + // Allow two arguments with the attestation uri. + if ctx.NArg() == 2 && ctx.String("attestation-uri") == "" { + return errs.TooFewArguments(ctx) + } + args := ctx.Args() subject := args.Get(0) crtFile, keyFile := args.Get(1), args.Get(2) @@ -183,10 +235,16 @@ func certificateAction(ctx *cli.Context) error { offline := ctx.Bool("offline") sans := ctx.StringSlice("san") - // offline and token are incompatible because the token is generated before - // the start of the offline CA. - if offline && tok != "" { + switch { + case offline && tok != "": + // offline and token are incompatible because the token is generated before + // the start of the offline CA. return errs.IncompatibleFlagWithFlag(ctx, "offline", "token") + case ctx.String("attestation-uri") != "" && ctx.String("kms") != "": + // attestation-uri and kms are incompatible because the ACME-DA flow + // expects all necessary parameters in the attestation-uri, and having + // both can be confusing. + return errs.IncompatibleFlagWithFlag(ctx, "attestation-uri", "kms") } // certificate flow unifies online and offline flows on a single api @@ -201,13 +259,11 @@ func certificateAction(ctx *cli.Context) error { return cautils.ACMECreateCertFlow(ctx, "") } if tok, err = flow.GenerateToken(ctx, subject, sans); err != nil { - switch k := err.(type) { - // Use the ACME flow with the step certificate authority. - case *cautils.ErrACMEToken: - return cautils.ACMECreateCertFlow(ctx, k.Name) - default: - return err + var acmeTokenErr *cautils.ACMETokenError + if errors.As(err, &acmeTokenErr) { + return cautils.ACMECreateCertFlow(ctx, acmeTokenErr.Name) } + return err } } diff --git a/command/ca/federation.go b/command/ca/federation.go index d6aac5d5..a1f3da74 100644 --- a/command/ca/federation.go +++ b/command/ca/federation.go @@ -6,16 +6,18 @@ import ( "os" "github.com/pkg/errors" + "github.com/urfave/cli" + "github.com/smallstep/certificates/api" "github.com/smallstep/certificates/ca" "github.com/smallstep/certificates/pki" - "github.com/smallstep/cli/crypto/pemutil" + "github.com/smallstep/cli-utils/command" + "github.com/smallstep/cli-utils/errs" + "github.com/smallstep/cli-utils/fileutil" + "github.com/smallstep/cli-utils/ui" + "go.step.sm/crypto/pemutil" + "github.com/smallstep/cli/flags" - "github.com/smallstep/cli/ui" - "github.com/smallstep/cli/utils" - "github.com/urfave/cli" - "go.step.sm/cli-utils/command" - "go.step.sm/cli-utils/errs" ) type flowType int @@ -168,7 +170,7 @@ func rootsAndFederationFlow(ctx *cli.Context, typ flowType) error { } if outFile := ctx.Args().Get(0); outFile != "" { - if err := utils.WriteFile(outFile, data, 0600); err != nil { + if err := fileutil.WriteFile(outFile, data, 0o600); err != nil { return err } diff --git a/command/ca/health.go b/command/ca/health.go index 37fbea88..3ad013c5 100644 --- a/command/ca/health.go +++ b/command/ca/health.go @@ -1,14 +1,17 @@ package ca import ( + "context" "fmt" "os" + "github.com/urfave/cli" + "github.com/smallstep/certificates/ca" "github.com/smallstep/certificates/pki" + "github.com/smallstep/cli-utils/errs" + "github.com/smallstep/cli/flags" - "github.com/urfave/cli" - "go.step.sm/cli-utils/errs" ) func healthCommand() cli.Command { @@ -73,11 +76,11 @@ func healthAction(ctx *cli.Context) error { var options []ca.ClientOption options = append(options, ca.WithRootFile(root)) - client, err := ca.NewClient(caURL, options...) + caClient, err := ca.NewClient(caURL, options...) if err != nil { return err } - r, err := client.Health() + r, err := caClient.HealthWithContext(context.Background()) if err != nil { return err } diff --git a/command/ca/health_test.go b/command/ca/health_test.go new file mode 100644 index 00000000..7b422b4b --- /dev/null +++ b/command/ca/health_test.go @@ -0,0 +1,99 @@ +package ca + +import ( + "encoding/json" + "fmt" + "net" + "net/http" + "path/filepath" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/urfave/cli" + + "go.step.sm/crypto/minica" + "go.step.sm/crypto/pemutil" + + "github.com/smallstep/certificates/authority/config" + stepca "github.com/smallstep/certificates/ca" +) + +// reservePort "reserves" a TCP port by opening a listener on a random +// port and immediately closing it. The port can then be assumed to be +// available for running a server on. +func reservePort(t *testing.T) (host, port string) { + t.Helper() + l, err := net.Listen("tcp", ":0") + require.NoError(t, err) + + address := l.Addr().String() + err = l.Close() + require.NoError(t, err) + + host, port, err = net.SplitHostPort(address) + require.NoError(t, err) + + return +} + +func Test_healthAction(t *testing.T) { + dir := t.TempDir() + m, err := minica.New(minica.WithName("Step Integration")) + require.NoError(t, err) + + rootFilepath := filepath.Join(dir, "root.crt") + _, err = pemutil.Serialize(m.Root, pemutil.WithFilename(rootFilepath)) + require.NoError(t, err) + + intermediateCertFilepath := filepath.Join(dir, "intermediate.crt") + _, err = pemutil.Serialize(m.Intermediate, pemutil.WithFilename(intermediateCertFilepath)) + require.NoError(t, err) + + intermediateKeyFilepath := filepath.Join(dir, "intermediate.key") + _, err = pemutil.Serialize(m.Signer, pemutil.WithFilename(intermediateKeyFilepath)) + require.NoError(t, err) + + // get a random address to listen on and connect to; currently no nicer way to get one before starting the server + // TODO(hs): find/implement a nicer way to expose the CA URL, similar to how e.g. httptest.Server exposes it? + host, port := reservePort(t) + + cfg := &config.Config{ + Root: []string{rootFilepath}, + IntermediateCert: intermediateCertFilepath, + IntermediateKey: intermediateKeyFilepath, + Address: net.JoinHostPort(host, port), // reuse the address that was just "reserved" + DNSNames: []string{"127.0.0.1", "[::1]", "localhost"}, + AuthorityConfig: &config.AuthConfig{ + AuthorityID: "stepca-test", + DeploymentType: "standalone-test", + }, + Logger: json.RawMessage(`{"format": "text"}`), + } + c, err := stepca.New(cfg) + require.NoError(t, err) + + var wg sync.WaitGroup + wg.Add(1) + + go func() { + defer wg.Done() + err = c.Run() + require.ErrorIs(t, err, http.ErrServerClosed) + }() + + caCommand := cli.Command{Name: "ca"} + caCommand.Subcommands = []cli.Command{healthCommand()} + + app := cli.NewApp() + app.Commands = cli.Commands{caCommand} + err = app.Run([]string{"step", "ca", "health", "--root", rootFilepath, "--ca-url", fmt.Sprintf("https://localhost:%s", port)}) + assert.NoError(t, err) + + // done testing; stop and wait for the server to quit + err = c.Stop() + require.NoError(t, err) + + wg.Wait() +} diff --git a/command/ca/init.go b/command/ca/init.go index a5e1901f..52871389 100644 --- a/command/ca/init.go +++ b/command/ca/init.go @@ -4,29 +4,30 @@ import ( "context" "crypto/rand" "crypto/x509" + stderrors "errors" "fmt" "io" + "net" "os" "strings" "time" "github.com/manifoldco/promptui" "github.com/pkg/errors" + "github.com/urfave/cli" + "github.com/smallstep/certificates/cas/apiv1" - "github.com/smallstep/certificates/kms" "github.com/smallstep/certificates/pki" - "github.com/smallstep/cli/crypto/pemutil" + "github.com/smallstep/cli-utils/errs" + "github.com/smallstep/cli-utils/step" + "github.com/smallstep/cli-utils/ui" + "go.step.sm/crypto/kms" + _ "go.step.sm/crypto/kms/azurekms" // enable azurekms + "go.step.sm/crypto/pemutil" + "github.com/smallstep/cli/flags" - "github.com/smallstep/cli/ui" "github.com/smallstep/cli/utils" "github.com/smallstep/cli/utils/cautils" - "github.com/urfave/cli" - - // Enable azurekms - _ "github.com/smallstep/certificates/kms/azurekms" - - "go.step.sm/cli-utils/errs" - "go.step.sm/cli-utils/step" ) func initCommand() cli.Command { @@ -35,12 +36,13 @@ func initCommand() cli.Command { Action: cli.ActionFunc(initAction), Usage: "initialize the CA PKI", UsageText: `**step ca init** -[**--root**=] [**--key**=] [**--pki**] [**--ssh**] +[**--root**=] [**--key**=] [**--key-password-file**=] [**--pki**] [**--ssh**] [**--helm**] [**--deployment-type**=] [**--name**=] [**--dns**=] [**--address**=
] [**--provisioner**=] -[**--provisioner-password-file**=] [**--password-file**=] -[**--ra**=] [**--kms**=] [**--with-ca-url**=] [**--no-db**] -[**--context**=] [**--profile**=] [**--authority**=]`, +[**--admin-subject**=] [**--provisioner-password-file**=] +[**--password-file**=] [**--ra**=] [**--kms**=] +[**--with-ca-url**=] [**--no-db**] [**--remote-management**] +[**--acme**] [**--context**=] [**--profile**=] [**--authority**=]`, Description: `**step ca init** command initializes a public key infrastructure (PKI) to be used by the Certificate Authority.`, Flags: []cli.Flag{ @@ -54,6 +56,10 @@ func initCommand() cli.Command { Usage: "The path of an existing key of the root certificate authority.", EnvVar: step.IgnoreEnvVar, }, + cli.StringFlag{ + Name: "key-password-file", + Usage: `The path to the containing the password to decrypt the existing root certificate key.`, + }, cli.BoolFlag{ Name: "pki", Usage: "Generate only the PKI without the CA configuration.", @@ -128,6 +134,30 @@ Use the '--dns' flag multiple times to configure multiple DNS names.`, **azurekms** : Use Azure Key Vault to manage X.509 and SSH keys. The key URIs have the following format .`, + }, + cli.StringFlag{ + Name: "kms-root", + Usage: `The kms used to generate the root certificate key. Examples are: + **azurekms** + : azurekms:name=my-root-key;vault=my-vault`, + }, + cli.StringFlag{ + Name: "kms-intermediate", + Usage: `The kms used to generate the intermediate certificate key. Examples are: + **azurekms** + : azurekms:name=my-intermediate-key;vault=my-vault`, + }, + cli.StringFlag{ + Name: "kms-ssh-host", + Usage: `The kms used to generate the key used to sign SSH host certificates. Examples are: + **azurekms** + : azurekms:name=my-host-key;vault=my-vault`, + }, + cli.StringFlag{ + Name: "kms-ssh-user", + Usage: `The kms used to generate the key used to sign SSH user certificates. Examples are: + **azurekms** + : azurekms:name=my-user-key;vault=my-vault`, }, cli.StringFlag{ Name: "issuer", @@ -171,6 +201,15 @@ Cloud.`, Name: "context", Usage: `The of the context for the new authority.`, }, + cli.BoolFlag{ + Name: "remote-management", + Usage: `Enable Remote Management. Defaults to false.`, + }, + cli.BoolFlag{ + Name: "acme", + Usage: `Create a default ACME provisioner. Defaults to false.`, + }, + flags.AdminSubject, flags.ContextProfile, flags.ContextAuthority, flags.HiddenNoContext, @@ -194,6 +233,9 @@ func initAction(ctx *cli.Context) (err error) { pkiOnly := ctx.Bool("pki") noDB := ctx.Bool("no-db") helm := ctx.Bool("helm") + enableRemoteManagement := ctx.Bool("remote-management") + addDefaultACMEProvisioner := ctx.Bool("acme") + firstSuperAdminSubject := ctx.String("admin-subject") switch { case root != "" && key == "": @@ -201,10 +243,14 @@ func initAction(ctx *cli.Context) (err error) { case root == "" && key != "": return errs.RequiredWithFlag(ctx, "key", "root") case root != "" && key != "": + opts := []pemutil.Options{} + if keyPasswordFile := ctx.String("key-password-file"); keyPasswordFile != "" { + opts = append(opts, pemutil.WithPasswordFile(keyPasswordFile)) + } if rootCrt, err = pemutil.ReadCertificate(root); err != nil { return err } - if rootKey, err = pemutil.Read(key); err != nil { + if rootKey, err = pemutil.Read(key, opts...); err != nil { return err } case ra != "" && ra != apiv1.CloudCAS && ra != apiv1.StepCAS: @@ -217,6 +263,19 @@ func initAction(ctx *cli.Context) (err error) { return errs.IncompatibleFlagWithFlag(ctx, "pki", "no-db") case pkiOnly && helm: return errs.IncompatibleFlagWithFlag(ctx, "pki", "helm") + case enableRemoteManagement && noDB: + // remote management via the Admin API requires a database configuration + return errs.IncompatibleFlagWithFlag(ctx, "remote-management", "no-db") + case addDefaultACMEProvisioner && noDB: + // ACME functionality requires a database configuration + return errs.IncompatibleFlagWithFlag(ctx, "acme", "no-db") + case firstSuperAdminSubject != "" && helm: + // providing the first super admin subject is not (yet) supported with Helm output + return errs.IncompatibleFlagWithFlag(ctx, "admin-subject", "helm") + case firstSuperAdminSubject != "" && !enableRemoteManagement: + // providing the first super admin subject only works with DB-backed provisioners, + // thus remote management should be enabled. + return errors.New("flag '--admin-subject' is only supported when '--remote-management' is enabled") } var password string @@ -378,14 +437,21 @@ func initAction(ctx *cli.Context) (err error) { } if deploymentType == pki.HostedDeployment { ui.Println() - ui.Println("Sorry, we can't create hosted authorities from the CLI yet. To create a hosted") - ui.Println("authority please visit:\n") + ui.Println("To use a Hosted authority, you'll need a Smallstep account. To create one,") + ui.Println("visit:\n") ui.Println(" 033円[1mhttps://u.step.sm/hosted033円[0m\n") - ui.Println("To connect to an existing hosted authority run:\n") + ui.Println("Then, to connect to your hosted authority, run:\n") ui.Println(" $ step ca bootstrap --team --authority ") ui.Println() return nil } + // When initializing a linked CA, providing the --acme flag doesn't currently + // result in the default ACME provisioner being added. We may want to support this + // for ease of use, but this seems to require a bit of refactoring when generating + // the full CA configuration with DB initialization. + if deploymentType != pki.StandaloneDeployment && addDefaultACMEProvisioner { + return fmt.Errorf("adding a default ACME provisioner by providing the --acme flag is not supported with deployment type %q.\nPlease use `step ca provisioner add acme --type ACME` after initializing your CA", deploymentType.String()) + } ui.Println("What would you like to name your new PKI?", ui.WithValue(ctx.String("name"))) name, err = ui.Prompt("(e.g. Smallstep)", ui.WithValidateNotEmpty(), ui.WithValue(ctx.String("name"))) @@ -399,7 +465,7 @@ func initAction(ctx *cli.Context) (err error) { if kmsName != "" { var rootURI, intermediateURI, sshHostURI, sshUserURI string keyManager, err = kms.New(context.Background(), kms.Options{ - Type: kmsName, + Type: kms.Type(kmsName), }) if err != nil { return err @@ -409,34 +475,38 @@ func initAction(ctx *cli.Context) (err error) { if v, ok := keyManager.(interface{ ValidateName(s string) error }); ok { validateFunc = v.ValidateName } else { - validateFunc = func(s string) error { + validateFunc = func(_ string) error { return nil } } if rootKey == nil { - ui.Println("What URI would you like to use for the root certificate key?") - rootURI, err = ui.Prompt("(e.g. azurekms:name=my-root-key;vault=my-vault)", ui.WithValidateFunc(validateFunc)) + ui.Println("What URI would you like to use for the root certificate key?", ui.WithValue(ctx.String("kms-root"))) + rootURI, err = ui.Prompt("(e.g. azurekms:name=my-root-key;vault=my-vault)", + ui.WithValidateFunc(validateFunc), ui.WithValue(ctx.String("kms-root"))) if err != nil { return err } } - ui.Println("What URI would you like to use for the intermediate certificate key?") - intermediateURI, err = ui.Prompt("(e.g. azurekms:name=my-intermediate-key;vault=my-vault)", ui.WithValidateFunc(validateFunc)) + ui.Println("What URI would you like to use for the intermediate certificate key?", ui.WithValue(ctx.String("kms-intermediate"))) + intermediateURI, err = ui.Prompt("(e.g. azurekms:name=my-intermediate-key;vault=my-vault)", + ui.WithValidateFunc(validateFunc), ui.WithValue(ctx.String("kms-intermediate"))) if err != nil { return err } if ctx.Bool("ssh") { - ui.Println("What URI would you like to use for the SSH host key?") - sshHostURI, err = ui.Prompt("(e.g. azurekms:name=my-host-key;vault=my-vault)", ui.WithValidateFunc(validateFunc)) + ui.Println("What URI would you like to use for the SSH host key?", ui.WithValue(ctx.String("kms-ssh-host"))) + sshHostURI, err = ui.Prompt("(e.g. azurekms:name=my-host-key;vault=my-vault)", + ui.WithValidateFunc(validateFunc), ui.WithValue(ctx.String("kms-ssh-host"))) if err != nil { return err } - ui.Println("What URI would you like to use for the SSH user key?") - sshUserURI, err = ui.Prompt("(e.g. azurekms:name=my-user-key;vault=my-vault)", ui.WithValidateFunc(validateFunc)) + ui.Println("What URI would you like to use for the SSH user key?", ui.WithValue(ctx.String("kms-ssh-user"))) + sshUserURI, err = ui.Prompt("(e.g. azurekms:name=my-user-key;vault=my-vault)", + ui.WithValidateFunc(validateFunc), ui.WithValue(ctx.String("kms-ssh-user"))) if err != nil { return err } @@ -460,29 +530,17 @@ func initAction(ctx *cli.Context) (err error) { if pkiOnly { pkiOpts = append(pkiOpts, pki.WithPKIOnly()) } else { - ui.Println("What DNS names or IP addresses would you like to add to your new CA?", + ui.Println("What DNS names or IP addresses will clients use to reach your CA?", ui.WithSliceValue(ctx.StringSlice("dns"))) - dnsValue, err := ui.Prompt("(e.g. ca.smallstep.com[,1.1.1.1,etc.])", + dnsValue, err := ui.Prompt("(e.g. ca.example.com[,10.1.2.3,etc.])", ui.WithSliceValue(ctx.StringSlice("dns"))) if err != nil { return err } - var ( - dnsValidator = ui.DNS() - dnsNames []string - ) - dnsValue = strings.ReplaceAll(dnsValue, " ", ",") - parts := strings.Split(dnsValue, ",") - for _, name := range parts { - if name == "" { - continue - } - if err := dnsValidator(name); err != nil { - return err - } - dnsNames = append(dnsNames, strings.TrimSpace(name)) + dnsNames, err := processDNSValue(dnsValue) + if err != nil { + return err } - if useContext { ctxName := ctx.String("context") if ctxName == "" { @@ -490,11 +548,11 @@ func initAction(ctx *cli.Context) (err error) { } ctxAuthority := ctx.String("authority") if ctxAuthority == "" { - ctxAuthority = dnsNames[0] + ctxAuthority = ctxName } ctxProfile := ctx.String("profile") if ctxProfile == "" { - ctxProfile = dnsNames[0] + ctxProfile = ctxName } if err := step.Contexts().Add(&step.Context{ Name: ctxName, @@ -512,17 +570,21 @@ func initAction(ctx *cli.Context) (err error) { } var address string - ui.Println("What IP and port will your new CA bind to?", ui.WithValue(ctx.String("address"))) + if helm { + ui.Println("What IP and port will your new CA bind to (it should match service.targetPort)?", ui.WithValue(ctx.String("address"))) + } else { + ui.Println("What IP and port will your new CA bind to? (:443 will bind to 0.0.0.0:443)", ui.WithValue(ctx.String("address"))) + } address, err = ui.Prompt("(e.g. :443 or 127.0.0.1:443)", ui.WithValidateFunc(ui.Address()), ui.WithValue(ctx.String("address"))) if err != nil { return err } - var provisioner string // Only standalone deployments will create an initial provisioner. // Linked or hosted deployments will use an OIDC token as the first // deployment. + var provisioner string if deploymentType == pki.StandaloneDeployment { ui.Println("What would you like to name the CA's first provisioner?", ui.WithValue(ctx.String("provisioner"))) provisioner, err = ui.Prompt("(e.g. you@smallstep.com)", @@ -539,7 +601,10 @@ func initAction(ctx *cli.Context) (err error) { pki.WithDeploymentType(deploymentType), ) if deploymentType == pki.StandaloneDeployment { - pkiOpts = append(pkiOpts, pki.WithProvisioner(provisioner)) + pkiOpts = append(pkiOpts, + pki.WithProvisioner(provisioner), + pki.WithSuperAdminSubject(firstSuperAdminSubject), + ) } if deploymentType == pki.LinkedDeployment { pkiOpts = append(pkiOpts, pki.WithAdmin()) @@ -552,6 +617,20 @@ func initAction(ctx *cli.Context) (err error) { if helm { pkiOpts = append(pkiOpts, pki.WithHelm()) } + + // enable the admin API if the `--remote-management` flag is provided. This will + // also result in the default provisioner being stored in the database and a default + // admin (called `step` by default, but can be named with --admin-subject) to be + // created for the default provisioner when the PKI is saved. + if enableRemoteManagement { + pkiOpts = append(pkiOpts, pki.WithAdmin()) + } + + // add a default ACME provisioner named `acme` if `--acme` flag is provided + // and configuring a standalone CA. Not yet supported for linked deployments. + if addDefaultACMEProvisioner && deploymentType == pki.StandaloneDeployment { + pkiOpts = append(pkiOpts, pki.WithACME()) + } } if ra != "" || kmsName != "" { @@ -742,3 +821,49 @@ func assertCryptoRand() error { } return nil } + +// processDNSValue reads DNS names from user supplied DNS value +// and transforms it into DNS names and IP addresses. +func processDNSValue(dnsValue string) ([]string, error) { + var ( + dnsValidator = ui.DNS() + dnsNames []string + ) + dnsValue = strings.ReplaceAll(dnsValue, " ", ",") + parts := strings.Split(dnsValue, ",") + if allEmpty(parts) { + return nil, stderrors.New("dns must not be empty") + } + for _, name := range parts { + if name == "" { // skip empty name + continue + } + if err := dnsValidator(name); err != nil { + return nil, err + } + dnsNames = append(dnsNames, normalize(strings.TrimSpace(name))) + } + return dnsNames, nil +} + +// normalize ensures an IPv6 hostname (i.e. [::1]) representation is +// converted to its IP representation (::1). +func normalize(name string) string { + if strings.HasPrefix(name, "[") && strings.HasSuffix(name, "]") { + if ip := net.ParseIP(name[1 : len(name)-1]); ip != nil { + name = ip.String() + } + } + return name +} + +// allEmpty loops through all strings in the slice and returns if +// all are empty (length 0). +func allEmpty(parts []string) bool { + for _, p := range parts { + if p != "" { + return false + } + } + return true +} diff --git a/command/ca/init_test.go b/command/ca/init_test.go new file mode 100644 index 00000000..06aa7579 --- /dev/null +++ b/command/ca/init_test.go @@ -0,0 +1,121 @@ +package ca + +import ( + "reflect" + "testing" + + _ "go.step.sm/crypto/kms/azurekms" +) + +func Test_processDNSValue(t *testing.T) { + tests := []struct { + name string + dnsValue string + want []string + wantErr bool + }{ + + { + name: "fail/empty", + dnsValue: "", + want: nil, + wantErr: true, + }, + { + name: "fail/empty-multiple", + dnsValue: ",,", + want: nil, + wantErr: true, + }, + { + name: "fail/dns", + dnsValue: "ca.smallstep.com:8443", + want: nil, + wantErr: true, + }, + { + name: "fail/ipv4", + dnsValue: "127.0.0.1:8080", + want: nil, + wantErr: true, + }, + { + name: "fail/ipv6", + dnsValue: ":::1", + want: nil, + wantErr: true, + }, + { + name: "ok/dns", + dnsValue: "ca.smallstep.com", + want: []string{"ca.smallstep.com"}, + wantErr: false, + }, + { + name: "ok/multi-dns", + dnsValue: "ca.smallstep.com,ca.localhost", + want: []string{"ca.smallstep.com", "ca.localhost"}, + wantErr: false, + }, + { + name: "ok/multi-dns-with-skip", + dnsValue: "ca.smallstep.com,ca.localhost,,test.localhost", + want: []string{"ca.smallstep.com", "ca.localhost", "test.localhost"}, + wantErr: false, + }, + { + name: "ok/multi-space-dns", + dnsValue: "ca.smallstep.com ca.localhost", + want: []string{"ca.smallstep.com", "ca.localhost"}, + wantErr: false, + }, + { + name: "ok/ipv4", + dnsValue: "127.0.0.1", + want: []string{"127.0.0.1"}, + wantErr: false, + }, + { + name: "ok/multi-ipv4", + dnsValue: "127.0.0.1,127.0.0.2", + want: []string{"127.0.0.1", "127.0.0.2"}, + wantErr: false, + }, + { + name: "ok/ipv6-no-brackets", + dnsValue: "::1", + want: []string{"::1"}, + wantErr: false, + }, + { + name: "ok/multi-ipv6-no-brackets", + dnsValue: "::1,::2", + want: []string{"::1", "::2"}, + wantErr: false, + }, + { + name: "ok/ipv6-with-brackets", + dnsValue: "[::1]", + want: []string{"::1"}, + wantErr: false, + }, + { + name: "ok/multi-ipv6-with-brackets", + dnsValue: "[::1] [::2]", + want: []string{"::1", "::2"}, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := processDNSValue(tt.dnsValue) + if (err != nil) != tt.wantErr { + t.Errorf("processDNSValue() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("processDNSValue() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/command/ca/policy/acme/acme.go b/command/ca/policy/acme/acme.go new file mode 100644 index 00000000..e08c75a7 --- /dev/null +++ b/command/ca/policy/acme/acme.go @@ -0,0 +1,31 @@ +package acme + +import ( + "context" + + "github.com/urfave/cli" + + "github.com/smallstep/cli/command/ca/policy/actions" + "github.com/smallstep/cli/command/ca/policy/policycontext" + "github.com/smallstep/cli/command/ca/policy/x509" +) + +// Command returns the ACME account policy subcommand. +func Command(ctx context.Context) cli.Command { + ctx = policycontext.WithACMEPolicyLevel(ctx) + return cli.Command{ + Name: "acme", + Usage: "manage certificate issuance policies for ACME accounts.", + UsageText: "**step ca policy acme** [arguments] [global-flags] [subcommand-flags]", + Description: `**step ca policy acme** command group provides facilities for managing certificate issuance policies for ACME accounts. + +Please note that certificate issuance policies for ACME accounts are currently only supported in Certificate Manager: https://u.step.sm/cm. + +`, + Subcommands: cli.Commands{ + actions.ViewCommand(ctx), + actions.RemoveCommand(ctx), + x509.Command(ctx), + }, + } +} diff --git a/command/ca/policy/actions/cn.go b/command/ca/policy/actions/cn.go new file mode 100644 index 00000000..8c75efea --- /dev/null +++ b/command/ca/policy/actions/cn.go @@ -0,0 +1,125 @@ +package actions + +import ( + "context" + "errors" + "fmt" + + "github.com/urfave/cli" + + "github.com/smallstep/cli-utils/errs" + + "github.com/smallstep/cli/command/ca/policy/policycontext" + "github.com/smallstep/cli/flags" + "github.com/smallstep/cli/internal/command" + "github.com/smallstep/cli/utils/cautils" +) + +// CommonNamesCommand returns the common names policy subcommand. +func CommonNamesCommand(ctx context.Context) cli.Command { + commandName := policycontext.GetPrefixedCommandUsage(ctx, "cn") + return cli.Command{ + Name: "cn", + Usage: "add or remove common names", + UsageText: fmt.Sprintf(`**%s** [**--remove**] +[**--provisioner**=] [**--eab-key-id**=] [**--eab-key-reference**=] +[**--admin-cert**=] [**--admin-key**=] [**--admin-subject**=] +[**--admin-provisioner**=] [**--admin-password-file**=] +[**--ca-url**=] [**--root**=] [**--context**=]`, commandName), + Description: fmt.Sprintf(`**%s** command manages common names in policies + +## EXAMPLES + +Allow "My CA Name" as Common Name in X.509 certificates on authority level +''' +$ step ca policy authority x509 allow cn "My CA Name" +''' + +Allow www.example.com as Common Name in X.509 certificates on authority level. +This can be used in case www.example.com is not allowed as a DNS SAN, but is +allowed to be used in the Common Name. +''' +$ step ca policy authority x509 allow cn www.example.com +''' + +Remove www.example.com from allowed Common Names in X.509 certificates on authority level. +''' +$ step ca policy authority x509 allow cn www.example.com --remove +''' + +Deny "My Bad CA Name" as Common Name in X.509 certificates on authority level +''' +$ step ca policy authority x509 deny cn "My Bad CA Name" +'''`, commandName), + Action: command.InjectContext( + ctx, + commonNamesAction, + ), + Flags: []cli.Flag{ + flags.Provisioner, + flags.EABKeyID, + flags.EABReference, + cli.BoolFlag{ + Name: "remove", + Usage: `removes the provided Common Names from the policy instead of adding them`, + }, + flags.AdminCert, + flags.AdminKey, + flags.AdminSubject, + flags.AdminProvisioner, + flags.AdminPasswordFile, + flags.CaURL, + flags.Root, + flags.Context, + }, + } +} + +func commonNamesAction(ctx context.Context) (err error) { + var ( + provisioner = retrieveAndUnsetProvisionerFlagIfRequired(ctx) + clictx = command.CLIContextFromContext(ctx) + args = clictx.Args() + ) + + if len(args) == 0 { + return errs.TooFewArguments(clictx) + } + + client, err := cautils.NewAdminClient(clictx) + if err != nil { + return fmt.Errorf("error creating admin client: %w", err) + } + + policy, err := retrieveAndInitializePolicy(ctx, client, provisioner) + if err != nil { + return fmt.Errorf("error retrieving policy: %w", err) + } + + shouldRemove := clictx.Bool("remove") + + switch { + case policycontext.IsSSHHostPolicy(ctx): + return errors.New("SSH host policy does not support Common Names") + case policycontext.IsSSHUserPolicy(ctx): + return errors.New("SSH user policy does not support Common Names") + case policycontext.IsX509Policy(ctx): + switch { + case policycontext.IsAllow(ctx): + policy.X509.Allow.CommonNames = addOrRemoveArguments(policy.X509.Allow.CommonNames, args, shouldRemove) + case policycontext.IsDeny(ctx): + policy.X509.Deny.CommonNames = addOrRemoveArguments(policy.X509.Deny.CommonNames, args, shouldRemove) + default: + panic("no allow nor deny context set") + } + default: + panic("no SSH nor X.509 context set") + } + + updatedPolicy, err := updatePolicy(ctx, client, policy, provisioner) + if err != nil { + return fmt.Errorf("error updating policy: %w", err) + } + + return prettyPrint(updatedPolicy) +} diff --git a/command/ca/policy/actions/dns.go b/command/ca/policy/actions/dns.go new file mode 100644 index 00000000..9fd30bf8 --- /dev/null +++ b/command/ca/policy/actions/dns.go @@ -0,0 +1,150 @@ +package actions + +import ( + "context" + "errors" + "fmt" + + "github.com/urfave/cli" + + "github.com/smallstep/cli-utils/errs" + + "github.com/smallstep/cli/command/ca/policy/policycontext" + "github.com/smallstep/cli/flags" + "github.com/smallstep/cli/internal/command" + "github.com/smallstep/cli/utils/cautils" +) + +// DNSCommand returns the dns policy subcommand. +func DNSCommand(ctx context.Context) cli.Command { + commandName := policycontext.GetPrefixedCommandUsage(ctx, "dns") + return cli.Command{ + Name: "dns", + Usage: "add or remove DNS domains", + UsageText: fmt.Sprintf(`**%s** [**--remove**] +[**--provisioner**=] [**--eab-key-id**=] [**--eab-key-reference**=] +[**--admin-cert**=] [**--admin-key**=] [**--admin-subject**=] +[**--admin-provisioner**=] [**--admin-password-file**=] +[**--ca-url**=] [**--root**=] [**--context**=]`, commandName), + Description: fmt.Sprintf(`**%s** command manages DNS domains in policies + +## EXAMPLES + +Allow www.example.com DNS in X.509 certificates on authority level +''' +$ step ca policy authority x509 allow dns www.example.com +''' + +Allow all DNS subdomains of "local" in X.509 certificates on authority level +''' +$ step ca policy authority x509 allow dns "*.local" +''' + +Deny DNS badhost.local in X.509 certificates on authority level +''' +$ step ca policy authority x509 deny dns "badhost.local" +''' + +Remove badhost.local from denied DNS names in X.509 certificates on authority level +''' +$ step ca policy authority x509 deny dns "badhost.local" --remove +''' + +Allow all DNS subdomains of "example.com" in X.509 certificates on provisioner level +''' +$ step ca policy provisioner x509 allow dns "*.example.com" --provisioner my_provisioner +''' + +Allow all DNS subdomains of "account1.acme.example.com" in X.509 certificates on ACME Account level +''' +$ step ca policy acme x509 allow dns "*.account1.acme.example.com" --provisioner my_acme_provisioner --reference account1 +''' + +Allow all DNS subdomains of "local" in SSH host certificates on authority level +''' +$ step ca policy authority ssh host allow dns "*.local" +''' + +Deny badsshhost.local in SSH host certificates on authority level +''' +$ step ca policy authority ssh host allow dns "badsshhost.local" +'''`, commandName), + Action: command.InjectContext( + ctx, + dnsAction, + ), + Flags: []cli.Flag{ + flags.Provisioner, + flags.EABKeyID, + flags.EABReference, + cli.BoolFlag{ + Name: "remove", + Usage: `removes the provided DNS names from the policy instead of adding them`, + }, + flags.AdminCert, + flags.AdminKey, + flags.AdminSubject, + flags.AdminProvisioner, + flags.AdminPasswordFile, + flags.CaURL, + flags.Root, + flags.Context, + }, + } +} + +func dnsAction(ctx context.Context) (err error) { + var ( + provisioner = retrieveAndUnsetProvisionerFlagIfRequired(ctx) + clictx = command.CLIContextFromContext(ctx) + args = clictx.Args() + ) + + if len(args) == 0 { + return errs.TooFewArguments(clictx) + } + + client, err := cautils.NewAdminClient(clictx) + if err != nil { + return fmt.Errorf("error creating admin client: %w", err) + } + + policy, err := retrieveAndInitializePolicy(ctx, client, provisioner) + if err != nil { + return fmt.Errorf("error retrieving policy: %w", err) + } + + shouldRemove := clictx.Bool("remove") + + switch { + case policycontext.IsSSHHostPolicy(ctx): + switch { + case policycontext.IsAllow(ctx): + policy.Ssh.Host.Allow.Dns = addOrRemoveArguments(policy.Ssh.Host.Allow.Dns, args, shouldRemove) + case policycontext.IsDeny(ctx): + policy.Ssh.Host.Deny.Dns = addOrRemoveArguments(policy.Ssh.Host.Deny.Dns, args, shouldRemove) + default: + panic("no allow nor deny context set") + } + case policycontext.IsSSHUserPolicy(ctx): + return errors.New("SSH user policy does not support DNS names") + case policycontext.IsX509Policy(ctx): + switch { + case policycontext.IsAllow(ctx): + policy.X509.Allow.Dns = addOrRemoveArguments(policy.X509.Allow.Dns, args, shouldRemove) + case policycontext.IsDeny(ctx): + policy.X509.Deny.Dns = addOrRemoveArguments(policy.X509.Deny.Dns, args, shouldRemove) + default: + panic("no allow nor deny context set") + } + default: + panic("no SSH nor X.509 context set") + } + + updatedPolicy, err := updatePolicy(ctx, client, policy, provisioner) + if err != nil { + return fmt.Errorf("error updating policy: %w", err) + } + + return prettyPrint(updatedPolicy) +} diff --git a/command/ca/policy/actions/emails.go b/command/ca/policy/actions/emails.go new file mode 100644 index 00000000..d92697b5 --- /dev/null +++ b/command/ca/policy/actions/emails.go @@ -0,0 +1,137 @@ +package actions + +import ( + "context" + "errors" + "fmt" + + "github.com/urfave/cli" + + "github.com/smallstep/cli-utils/errs" + + "github.com/smallstep/cli/command/ca/policy/policycontext" + "github.com/smallstep/cli/flags" + "github.com/smallstep/cli/internal/command" + "github.com/smallstep/cli/utils/cautils" +) + +// EmailCommand returns the x509 email subcommand +func EmailCommand(ctx context.Context) cli.Command { + commandName := policycontext.GetPrefixedCommandUsage(ctx, "email") + return cli.Command{ + Name: "email", + Usage: "add or remove email addresses", + UsageText: fmt.Sprintf(`**%s** [**--remove**] [**--provisioner**=] +[**--admin-cert**=] [**--admin-key**=] [**--admin-subject**=] +[**--admin-provisioner**=] [**--admin-password-file**=] +[**--ca-url**=] [**--root**=] [**--context**=]`, commandName), + Description: fmt.Sprintf(`**%s** command manages email addresses and domains in policies + +## EXAMPLES + +Allow all email addresses for the example.com domain in X.509 certificates on authority level +''' +$ step ca policy authority x509 allow email @example.com +''' + +Remove the email addresses for the example.com domain in X.509 certificates on authority level +''' +$ step ca policy authority x509 allow email @example.com --remove +''' + +Deny badmail@example.com in X.509 certificates on authority level +''' +$ step ca policy authority x509 deny email badmail@example.com +''' + +Allow all email addresses for the example.com domain in X.509 certificates on provisioner level +''' +$ step ca policy provisioner x509 allow email @example.com --provisioner my_provisioner +''' + +Allow all local parts for the example.com domain in SSH user certificates on provisioner level +''' +$ step ca policy provisioner ssh user allow email @example.com --provisioner my_provisioner +''' + +Deny root@example.com domain in SSH user certificates on provisioner level +''' +$ step ca policy provisioner ssh user deny email @example.com --provisioner my_provisioner +'''`, commandName), + Action: command.InjectContext( + ctx, + emailAction, + ), + Flags: []cli.Flag{ + flags.Provisioner, + cli.BoolFlag{ + Name: "remove", + Usage: `removes the provided emails from the policy instead of adding them`, + }, + flags.AdminCert, + flags.AdminKey, + flags.AdminSubject, + flags.AdminProvisioner, + flags.AdminPasswordFile, + flags.CaURL, + flags.Root, + flags.Context, + }, + } +} + +func emailAction(ctx context.Context) (err error) { + var ( + provisioner = retrieveAndUnsetProvisionerFlagIfRequired(ctx) + clictx = command.CLIContextFromContext(ctx) + args = clictx.Args() + ) + + if len(args) == 0 { + return errs.TooFewArguments(clictx) + } + + client, err := cautils.NewAdminClient(clictx) + if err != nil { + return fmt.Errorf("error creating admin client: %w", err) + } + + policy, err := retrieveAndInitializePolicy(ctx, client, provisioner) + if err != nil { + return err + } + + shouldRemove := clictx.Bool("remove") + + switch { + case policycontext.IsSSHHostPolicy(ctx): + return errors.New("SSH host policy does not support emails") + case policycontext.IsSSHUserPolicy(ctx): + switch { + case policycontext.IsAllow(ctx): + policy.Ssh.User.Allow.Emails = addOrRemoveArguments(policy.Ssh.User.Allow.Emails, args, shouldRemove) + case policycontext.IsDeny(ctx): + policy.Ssh.User.Deny.Emails = addOrRemoveArguments(policy.Ssh.User.Deny.Emails, args, shouldRemove) + default: + panic("no allow nor deny context set") + } + case policycontext.IsX509Policy(ctx): + switch { + case policycontext.IsAllow(ctx): + policy.X509.Allow.Emails = addOrRemoveArguments(policy.X509.Allow.Emails, args, shouldRemove) + case policycontext.IsDeny(ctx): + policy.X509.Deny.Emails = addOrRemoveArguments(policy.X509.Deny.Emails, args, shouldRemove) + default: + panic("no allow nor deny context set") + } + default: + panic("no SSH nor X.509 context set") + } + + updatedPolicy, err := updatePolicy(ctx, client, policy, provisioner) + if err != nil { + return fmt.Errorf("error updating policy: %w", err) + } + + return prettyPrint(updatedPolicy) +} diff --git a/command/ca/policy/actions/ips.go b/command/ca/policy/actions/ips.go new file mode 100644 index 00000000..992861d7 --- /dev/null +++ b/command/ca/policy/actions/ips.go @@ -0,0 +1,170 @@ +package actions + +import ( + "context" + "errors" + "fmt" + + "github.com/urfave/cli" + + "github.com/smallstep/cli-utils/errs" + + "github.com/smallstep/cli/command/ca/policy/policycontext" + "github.com/smallstep/cli/flags" + "github.com/smallstep/cli/internal/command" + "github.com/smallstep/cli/utils/cautils" +) + +// IPCommand returns the policy subcommand. +func IPCommand(ctx context.Context) cli.Command { + commandName := policycontext.GetPrefixedCommandUsage(ctx, "ip") + return cli.Command{ + Name: "ip", + Usage: "add or remove ip addresses", + UsageText: fmt.Sprintf(`**%s** [**--remove**] +[**--provisioner**=] [**--eab-key-id**=] [**--eab-key-reference**=] +[**--admin-cert**=] [**--admin-key**=] [**--admin-subject**=] +[**--admin-provisioner**=] [**--admin-password-file**=] +[**--ca-url**=] [**--root**=] [**--context**=]`, commandName), + Description: fmt.Sprintf(`**%s** command manages IP addresses and ranges in policies + +## EXAMPLES + +Allow IP address 127.0.0.1 in X.509 certificates on authority level +''' +$ step ca policy authority x509 allow ip 127.0.0.1 +''' + +Allow IP address range 10.0.0.0/24 in X.509 certificates on authority level +''' +$ step ca policy authority x509 allow ip 10.0.0.0/24 +''' + +Deny IP address 10.0.0.30 in X.509 certificates on authority level +''' +$ step ca policy authority x509 deny ip 10.0.0.30 +''' + +Remove IP address range 10.0.0.0/24 from being allowed in X.509 certificates on authority level +''' +$ step ca policy authority x509 allow ip 10.0.0.0/24 --remove +''' + +Allow IP address range 10.10.0.0/24 in X.509 certificates on provisioner level +''' +$ step ca policy provisioner x509 allow ip 10.10.0.0/24 --provisioner my_provisioner +''' + +Deny IP address 10.10.0.50 in X.509 certificates on provisioner level +''' +$ step ca policy provisioner x509 deny ip 10.10.0.50 --provisioner my_provisioner +''' + +Remove IP address 10.10.0.50 from being denied in X.509 certificates on provisioner level +''' +$ step ca policy provisioner x509 deny ip 10.10.0.50 --provisioner my_provisioner --remove +''' + +Allow IP address range 10.20.0.0/24 in X.509 certificates on ACME account level by EAB key reference +''' +$ step ca policy provisioner x509 allow ip 10.10.0.0/24 --provisioner my_acme_provisioner --eab-key-reference my_ref +''' + +Deny IP address 10.20.0.70 in X.509 certificates on ACME account level by EAB key reference +''' +$ step ca policy provisioner x509 deny ip 10.20.0.70 --provisioner my_acme_provisioner --eab-key-reference my_ref +''' + +Remove IP address 10.20.0.70 from being denied in X.509 certificates on ACME account level by EAB key reference +''' +$ step ca policy provisioner x509 deny ip 10.20.0.70 --provisioner my_acme_provisioner --eab-key-reference my_ref --remove +''' + +Allow IP address range 192.168.0.0/24 in SSH host certificates on authority level +''' +$ step ca policy authority ssh host allow ip 192.168.0.0/24 +''' + +Deny IP address 192.168.0.40 in SSH host certificates on authority level +''' +$ step ca policy authority ssh host deny ip 192.168.0.40 +'''`, commandName), + Action: command.InjectContext( + ctx, + ipAction, + ), + Flags: []cli.Flag{ + flags.Provisioner, + flags.EABKeyID, + flags.EABReference, + cli.BoolFlag{ + Name: "remove", + Usage: `removes the provided IPs from the policy instead of adding them`, + }, + flags.AdminCert, + flags.AdminKey, + flags.AdminSubject, + flags.AdminProvisioner, + flags.AdminPasswordFile, + flags.CaURL, + flags.Root, + flags.Context, + }, + } +} + +func ipAction(ctx context.Context) (err error) { + var ( + provisioner = retrieveAndUnsetProvisionerFlagIfRequired(ctx) + clictx = command.CLIContextFromContext(ctx) + args = clictx.Args() + ) + + if len(args) == 0 { + return errs.TooFewArguments(clictx) + } + + client, err := cautils.NewAdminClient(clictx) + if err != nil { + return fmt.Errorf("error creating admin client: %w", err) + } + + policy, err := retrieveAndInitializePolicy(ctx, client, provisioner) + if err != nil { + return err + } + + shouldRemove := clictx.Bool("remove") + + switch { + case policycontext.IsSSHHostPolicy(ctx): + switch { + case policycontext.IsAllow(ctx): + policy.Ssh.Host.Allow.Ips = addOrRemoveArguments(policy.Ssh.Host.Allow.Ips, args, shouldRemove) + case policycontext.IsDeny(ctx): + policy.Ssh.Host.Deny.Ips = addOrRemoveArguments(policy.Ssh.Host.Deny.Ips, args, shouldRemove) + default: + panic("no allow nor deny context set") + } + case policycontext.IsSSHUserPolicy(ctx): + return errors.New("SSH user policy does not support IP addresses or ranges") + case policycontext.IsX509Policy(ctx): + switch { + case policycontext.IsAllow(ctx): + policy.X509.Allow.Ips = addOrRemoveArguments(policy.X509.Allow.Ips, args, shouldRemove) + case policycontext.IsDeny(ctx): + policy.X509.Deny.Ips = addOrRemoveArguments(policy.X509.Deny.Ips, args, shouldRemove) + default: + panic("no allow nor deny context set") + } + default: + panic("no SSH nor X.509 context set") + } + + updatedPolicy, err := updatePolicy(ctx, client, policy, provisioner) + if err != nil { + return fmt.Errorf("error updating policy: %w", err) + } + + return prettyPrint(updatedPolicy) +} diff --git a/command/ca/policy/actions/policy.go b/command/ca/policy/actions/policy.go new file mode 100644 index 00000000..82b77f95 --- /dev/null +++ b/command/ca/policy/actions/policy.go @@ -0,0 +1,216 @@ +package actions + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + + "google.golang.org/protobuf/encoding/protojson" + + "github.com/smallstep/certificates/ca" + "github.com/smallstep/cli-utils/errs" + "github.com/smallstep/linkedca" + + "github.com/smallstep/cli/command/ca/policy/policycontext" + "github.com/smallstep/cli/internal/command" +) + +func retrieveAndUnsetProvisionerFlagIfRequired(ctx context.Context) string { + // when managing policies on the authority level there's no need + // to select a provisioner, so the flag does not need to be unset. + if policycontext.IsAuthorityPolicyLevel(ctx) { + return "" + } + + clictx := command.CLIContextFromContext(ctx) + provisioner := clictx.String("provisioner") + + // unset the provisioner and issuer flag values, so that they're not used + // automatically in token flows. + if err := clictx.Set("provisioner", ""); err != nil { + panic(fmt.Errorf("failed unsetting provisioner flag: %w", err)) + } + if err := clictx.Set("issuer", ""); err != nil { + panic(fmt.Errorf("failed unsetting issuer flag: %w", err)) + } + + return provisioner +} + +func retrieveAndInitializePolicy(ctx context.Context, client *ca.AdminClient, provisioner string) (*linkedca.Policy, error) { + var ( + clictx = command.CLIContextFromContext(ctx) + reference = clictx.String("eab-key-reference") + keyID = clictx.String("eab-key-id") + policy *linkedca.Policy + err error + ) + + switch { + case policycontext.IsAuthorityPolicyLevel(ctx): + policy, err = client.GetAuthorityPolicy() + case policycontext.IsProvisionerPolicyLevel(ctx): + if provisioner == "" { + return nil, errs.RequiredFlag(clictx, "provisioner") + } + policy, err = client.GetProvisionerPolicy(provisioner) + case policycontext.IsACMEPolicyLevel(ctx): + if provisioner == "" { + return nil, errs.RequiredFlag(clictx, "provisioner") + } + if reference == "" && keyID == "" { + return nil, errs.RequiredOrFlag(clictx, "eab-key-reference", "eab-key-id") + } + policy, err = client.GetACMEPolicy(provisioner, reference, keyID) + default: + panic("no context for policy retrieval set") + } + + if err != nil { + var ae *ca.AdminClientError + if errors.As(err, &ae) && ae.Type == "notFound" { // TODO: use constant? + // when a policy doesn't exist yet, create a new, empty policy and + // send it to the CA. + newPolicy := newPolicy() + switch { + case policycontext.IsAuthorityPolicyLevel(ctx): + policy, err = client.CreateAuthorityPolicy(newPolicy) + case policycontext.IsProvisionerPolicyLevel(ctx): + policy, err = client.CreateProvisionerPolicy(provisioner, newPolicy) + case policycontext.IsACMEPolicyLevel(ctx): + policy, err = client.CreateACMEPolicy(provisioner, reference, keyID, newPolicy) + } + if err != nil { + return nil, fmt.Errorf("error creating policy: %w", err) + } + } else { + return nil, fmt.Errorf("error retrieving policy: %w", err) + } + } + + // ensure all policy properties are set + policy = initPolicy(policy) + + return policy, nil +} + +func remove(item string, items []string) []string { + var i int + for _, v := range items { + if item != v { + items[i] = v + i++ + } + } + return items[:i] +} + +func newPolicy() *linkedca.Policy { + return initPolicy(nil) +} + +// addOrRemoveArguments adds or removes args to/from existingNames +func addOrRemoveArguments(existingNames, args []string, shouldRemove bool) []string { + if shouldRemove { + for _, name := range args { + existingNames = remove(name, existingNames) + } + } else { + existingNames = append(existingNames, args...) + } + return existingNames +} + +func initPolicy(p *linkedca.Policy) *linkedca.Policy { + if p == nil { + p = &linkedca.Policy{} + } + if p.X509 == nil { + p.X509 = &linkedca.X509Policy{} + p.X509.AllowWildcardNames = false + } + if p.X509.Allow == nil { + p.X509.Allow = &linkedca.X509Names{} + } + if p.X509.Deny == nil { + p.X509.Deny = &linkedca.X509Names{} + } + if p.Ssh == nil { + p.Ssh = &linkedca.SSHPolicy{} + } + if p.Ssh.Host == nil { + p.Ssh.Host = &linkedca.SSHHostPolicy{} + } + if p.Ssh.Host.Allow == nil { + p.Ssh.Host.Allow = &linkedca.SSHHostNames{} + } + if p.Ssh.Host.Deny == nil { + p.Ssh.Host.Deny = &linkedca.SSHHostNames{} + } + if p.Ssh.User == nil { + p.Ssh.User = &linkedca.SSHUserPolicy{} + } + if p.Ssh.User.Allow == nil { + p.Ssh.User.Allow = &linkedca.SSHUserNames{} + } + if p.Ssh.User.Deny == nil { + p.Ssh.User.Deny = &linkedca.SSHUserNames{} + } + return p +} + +func updatePolicy(ctx context.Context, client *ca.AdminClient, policy *linkedca.Policy, provisioner string) (*linkedca.Policy, error) { + var ( + clictx = command.CLIContextFromContext(ctx) + reference = clictx.String("eab-key-reference") + keyID = clictx.String("eab-key-id") + updatedPolicy *linkedca.Policy + err error + ) + + // deduplicate values before sending them + policy.Deduplicate() + + switch { + case policycontext.IsAuthorityPolicyLevel(ctx): + updatedPolicy, err = client.UpdateAuthorityPolicy(policy) + case policycontext.IsProvisionerPolicyLevel(ctx): + if provisioner == "" { + return nil, errs.RequiredFlag(clictx, "provisioner") + } + updatedPolicy, err = client.UpdateProvisionerPolicy(provisioner, policy) + case policycontext.IsACMEPolicyLevel(ctx): + if provisioner == "" { + return nil, errs.RequiredFlag(clictx, "provisioner") + } + if reference == "" && keyID == "" { + return nil, errs.RequiredOrFlag(clictx, "eab-key-reference", "eab-key-id") + } + updatedPolicy, err = client.UpdateACMEPolicy(provisioner, reference, keyID, policy) + default: + panic("no context for policy update set") + } + + if err != nil { + return nil, err + } + + return updatedPolicy, nil +} + +func prettyPrint(policy *linkedca.Policy) error { + b, err := protojson.Marshal(policy) + if err != nil { + return fmt.Errorf("error marshaling policy: %w", err) + } + var buf bytes.Buffer + if err := json.Indent(&buf, b, "", " "); err != nil { + return fmt.Errorf("error indenting policy JSON representation: %w", err) + } + + fmt.Println(buf.String()) + + return nil +} diff --git a/command/ca/policy/actions/policy_test.go b/command/ca/policy/actions/policy_test.go new file mode 100644 index 00000000..ff14111e --- /dev/null +++ b/command/ca/policy/actions/policy_test.go @@ -0,0 +1,50 @@ +package actions + +import ( + "reflect" + "testing" +) + +func Test_remove(t *testing.T) { + type args struct { + item string + items []string + } + tests := []struct { + name string + args args + want []string + }{ + { + name: "empty-slice", + args: args{ + item: "test", + items: []string{}, + }, + want: []string{}, + }, + { + name: "empty-item", + args: args{ + item: "", + items: []string{"item"}, + }, + want: []string{"item"}, + }, + { + name: "ok", + args: args{ + item: "item1", + items: []string{"item1", "item2"}, + }, + want: []string{"item2"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := remove(tt.args.item, tt.args.items); !reflect.DeepEqual(got, tt.want) { + t.Errorf("remove() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/command/ca/policy/actions/principals.go b/command/ca/policy/actions/principals.go new file mode 100644 index 00000000..2b845ada --- /dev/null +++ b/command/ca/policy/actions/principals.go @@ -0,0 +1,132 @@ +package actions + +import ( + "context" + "errors" + "fmt" + + "github.com/urfave/cli" + + "github.com/smallstep/cli-utils/errs" + + "github.com/smallstep/cli/command/ca/policy/policycontext" + "github.com/smallstep/cli/flags" + "github.com/smallstep/cli/internal/command" + "github.com/smallstep/cli/utils/cautils" +) + +// PrincipalsCommand returns the principal policy subcommand. +func PrincipalsCommand(ctx context.Context) cli.Command { + commandName := policycontext.GetPrefixedCommandUsage(ctx, "principal") + return cli.Command{ + Name: "principal", + Usage: "add or remove principals", + UsageText: fmt.Sprintf(`**%s** [**--remove**] [**--provisioner**=] +[**--admin-cert**=] [**--admin-key**=] [**--admin-subject**=] +[**--admin-provisioner**=] [**--admin-password-file**=] +[**--ca-url**=] [**--root**=] [**--context**=]`, commandName), + Description: fmt.Sprintf(`**%s** command manages principals in SSH policies + +## EXAMPLES + +Allow all principals in SSH host certificates on authority level +''' +$ step ca policy authority ssh host allow principal "*" +''' + +Allow all principals in SSH user certificates on authority level +''' +$ step ca policy authority ssh user allow principal "*" +''' + +Allow principal machine-name in SSH host certificates on provisioner level +''' +$ step ca policy provisioner ssh host allow principal machine-name --provisioner my_ssh_host_provisioner +''' + +Allow principal user in SSH user certificates on provisioner level +''' +$ step ca policy provisioner ssh host allow principal user --provisioner my_ssh_user_provisioner +''' + +Deny principal root in SSH user certificates on provisioner level +''' +$ step ca policy provisioner ssh host deny principal root --provisioner my_ssh_user_provisioner +'''`, commandName), + Action: command.InjectContext( + ctx, + principalAction, + ), + Flags: []cli.Flag{ + flags.Provisioner, + cli.BoolFlag{ + Name: "remove", + Usage: `removes the provided Principals from the policy instead of adding them`, + }, + flags.AdminCert, + flags.AdminKey, + flags.AdminSubject, + flags.AdminProvisioner, + flags.AdminPasswordFile, + flags.CaURL, + flags.Root, + flags.Context, + }, + } +} + +func principalAction(ctx context.Context) (err error) { + var ( + provisioner = retrieveAndUnsetProvisionerFlagIfRequired(ctx) + clictx = command.CLIContextFromContext(ctx) + args = clictx.Args() + ) + + if len(args) == 0 { + return errs.TooFewArguments(clictx) + } + + client, err := cautils.NewAdminClient(clictx) + if err != nil { + return fmt.Errorf("error creating admin client: %w", err) + } + + policy, err := retrieveAndInitializePolicy(ctx, client, provisioner) + if err != nil { + return err + } + + shouldRemove := clictx.Bool("remove") + + switch { + case policycontext.IsSSHHostPolicy(ctx): + switch { + case policycontext.IsAllow(ctx): + policy.Ssh.Host.Allow.Principals = addOrRemoveArguments(policy.Ssh.Host.Allow.Principals, args, shouldRemove) + case policycontext.IsDeny(ctx): + policy.Ssh.Host.Deny.Principals = addOrRemoveArguments(policy.Ssh.Host.Deny.Principals, args, shouldRemove) + default: + panic("no allow nor deny context set") + } + case policycontext.IsSSHUserPolicy(ctx): + switch { + case policycontext.IsAllow(ctx): + policy.Ssh.User.Allow.Principals = addOrRemoveArguments(policy.Ssh.User.Allow.Principals, args, shouldRemove) + case policycontext.IsDeny(ctx): + policy.Ssh.User.Deny.Principals = addOrRemoveArguments(policy.Ssh.User.Deny.Principals, args, shouldRemove) + default: + panic("no allow nor deny context set") + } + case policycontext.IsX509Policy(ctx): + return errors.New("the X.509 policy does not support principals") + default: + panic("no SSH nor X.509 context set") + } + + updatedPolicy, err := updatePolicy(ctx, client, policy, provisioner) + if err != nil { + return fmt.Errorf("error updating policy: %w", err) + } + + return prettyPrint(updatedPolicy) +} diff --git a/command/ca/policy/actions/remove.go b/command/ca/policy/actions/remove.go new file mode 100644 index 00000000..7171329f --- /dev/null +++ b/command/ca/policy/actions/remove.go @@ -0,0 +1,117 @@ +package actions + +import ( + "context" + "errors" + "fmt" + + "github.com/urfave/cli" + + "github.com/smallstep/certificates/ca" + "github.com/smallstep/cli-utils/errs" + + "github.com/smallstep/cli/command/ca/policy/policycontext" + "github.com/smallstep/cli/flags" + "github.com/smallstep/cli/internal/command" + "github.com/smallstep/cli/utils/cautils" +) + +// RemoveCommand returns the policy remove subcommand. +func RemoveCommand(ctx context.Context) cli.Command { + commandName := policycontext.GetPrefixedCommandUsage(ctx, "remove") + return cli.Command{ + Name: "remove", + Usage: "remove certificate issuance policy", + UsageText: fmt.Sprintf(`**%s** +[**--provisioner**=] [**--eab-key-id**=] [**--eab-key-reference**=] +[**--admin-cert**=] [**--admin-key**=] [**--admin-subject**=] +[**--admin-provisioner**=] [**--admin-password-file**=] +[**--ca-url**=] [**--root**=] [**--context**=]`, commandName), + Description: fmt.Sprintf(`**%s** removes a certificate issuance policy. + +## EXAMPLES + +Remove the authority certificate issuance policy +''' +$ step ca policy authority remove +''' + +Remove a provisioner certificate issuance policy +''' +$ step ca policy provisioner remove --provisioner my_provisioner +''' + +Remove an ACME EAB certificate issuance policy by reference +''' +$ step ca policy acme remove --provisioner my_acme_provisioner --eab-key-reference my_reference +''' + +Remove an ACME EAB certificate issuance policy by EAB Key ID +''' +$ step ca policy acme remove --provisioner my_acme_provisioner --eab-key-id "lUOTGwvFQADjk8nxsVufbhyTOwrFmvO2" +'''`, commandName), + Action: command.InjectContext( + ctx, + removeAction, + ), + Flags: []cli.Flag{ + flags.Provisioner, + flags.EABKeyID, + flags.EABReference, + flags.AdminCert, + flags.AdminKey, + flags.AdminSubject, + flags.AdminProvisioner, + flags.AdminPasswordFile, + flags.CaURL, + flags.Root, + flags.Context, + }, + } +} + +func removeAction(ctx context.Context) (err error) { + var ( + provisioner = retrieveAndUnsetProvisionerFlagIfRequired(ctx) + clictx = command.CLIContextFromContext(ctx) + reference = clictx.String("eab-key-reference") + keyID = clictx.String("eab-key-id") + ) + + client, err := cautils.NewAdminClient(clictx) + if err != nil { + return fmt.Errorf("error creating admin client: %w", err) + } + + switch { + case policycontext.IsAuthorityPolicyLevel(ctx): + err = client.RemoveAuthorityPolicy() + case policycontext.IsProvisionerPolicyLevel(ctx): + if provisioner == "" { + return errs.RequiredFlag(clictx, "provisioner") + } + err = client.RemoveProvisionerPolicy(provisioner) + case policycontext.IsACMEPolicyLevel(ctx): + if provisioner == "" { + return errs.RequiredFlag(clictx, "provisioner") + } + if reference == "" && keyID == "" { + return errs.RequiredOrFlag(clictx, "eab-key-reference", "eab-key-id") + } + err = client.RemoveACMEPolicy(provisioner, reference, keyID) + default: + panic("no context for policy retrieval set") + } + + if err != nil { + var ae *ca.AdminClientError + if errors.As(err, &ae) && ae.Type == "notFound" { + return errors.New("certificate issuance policy does not exist") + } + return fmt.Errorf("error deleting certificate issuance policy: %w", err) + } + + fmt.Println("policy deleted") + + return nil +} diff --git a/command/ca/policy/actions/uris.go b/command/ca/policy/actions/uris.go new file mode 100644 index 00000000..203625e6 --- /dev/null +++ b/command/ca/policy/actions/uris.go @@ -0,0 +1,120 @@ +package actions + +import ( + "context" + "errors" + "fmt" + + "github.com/urfave/cli" + + "github.com/smallstep/cli-utils/errs" + + "github.com/smallstep/cli/command/ca/policy/policycontext" + "github.com/smallstep/cli/flags" + "github.com/smallstep/cli/internal/command" + "github.com/smallstep/cli/utils/cautils" +) + +// URICommand returns the policy subcommand. +func URICommand(ctx context.Context) cli.Command { + commandName := policycontext.GetPrefixedCommandUsage(ctx, "uri") + return cli.Command{ + Name: "uri", + Usage: "add or remove URI domains", + UsageText: fmt.Sprintf(`**%s** [**--remove**] [**--provisioner**=] +[**--admin-cert**=] [**--admin-key**=] [**--admin-subject**=] +[**--admin-provisioner**=] [**--admin-password-file**=] +[**--ca-url**=] [**--root**=] [**--context**=]`, commandName), + Description: fmt.Sprintf(`**%s** command manages URI domains in policies + +## EXAMPLES + +Allow all URI subdomains of "local" in X.509 certificates on authority level +''' +$ step ca policy authority x509 allow uri "*.local" +''' + +Deny URI badhost.local domain in X.509 certificates on authority level +''' +$ step ca policy authority x509 deny uri badhost.local +''' + +Remove badhost.local from denied URI domain names in X.509 certificates on authority level +''' +$ step ca policy authority x509 deny uri badhost.local --remove +''' + +Allow all URI subdomains of "example.com" in X.509 certificates on provisioner level +''' +$ step ca policy provisioner x509 allow uri "*.example.com" --provisioner my_provisioner +'''`, commandName), + Action: command.InjectContext( + ctx, + uriAction, + ), + Flags: []cli.Flag{ + flags.Provisioner, + cli.BoolFlag{ + Name: "remove", + Usage: `removes the provided URIs from the policy instead of adding them`, + }, + flags.AdminCert, + flags.AdminKey, + flags.AdminSubject, + flags.AdminProvisioner, + flags.AdminPasswordFile, + flags.CaURL, + flags.Root, + flags.Context, + }, + } +} + +func uriAction(ctx context.Context) (err error) { + var ( + provisioner = retrieveAndUnsetProvisionerFlagIfRequired(ctx) + clictx = command.CLIContextFromContext(ctx) + args = clictx.Args() + ) + + if len(args) == 0 { + return errs.TooFewArguments(clictx) + } + + client, err := cautils.NewAdminClient(clictx) + if err != nil { + return fmt.Errorf("error creating admin client: %w", err) + } + + policy, err := retrieveAndInitializePolicy(ctx, client, provisioner) + if err != nil { + return fmt.Errorf("error retrieving policy: %w", err) + } + + shouldRemove := clictx.Bool("remove") + + switch { + case policycontext.IsSSHHostPolicy(ctx): + return errors.New("SSH host policy does not support URIs") + case policycontext.IsSSHUserPolicy(ctx): + return errors.New("SSH user policy does not support URIs") + case policycontext.IsX509Policy(ctx): + switch { + case policycontext.IsAllow(ctx): + policy.X509.Allow.Uris = addOrRemoveArguments(policy.X509.Allow.Uris, args, shouldRemove) + case policycontext.IsDeny(ctx): + policy.X509.Deny.Uris = addOrRemoveArguments(policy.X509.Deny.Uris, args, shouldRemove) + default: + panic("no allow nor deny context set") + } + default: + panic("no SSH nor X.509 context set") + } + + updatedPolicy, err := updatePolicy(ctx, client, policy, provisioner) + if err != nil { + return fmt.Errorf("error updating policy: %w", err) + } + + return prettyPrint(updatedPolicy) +} diff --git a/command/ca/policy/actions/view.go b/command/ca/policy/actions/view.go new file mode 100644 index 00000000..543dc36c --- /dev/null +++ b/command/ca/policy/actions/view.go @@ -0,0 +1,121 @@ +package actions + +import ( + "context" + "errors" + "fmt" + + "github.com/urfave/cli" + + "github.com/smallstep/certificates/ca" + "github.com/smallstep/cli-utils/errs" + "github.com/smallstep/linkedca" + + "github.com/smallstep/cli/command/ca/policy/policycontext" + "github.com/smallstep/cli/flags" + "github.com/smallstep/cli/internal/command" + "github.com/smallstep/cli/utils/cautils" +) + +// ViewCommand returns the policy view subcommand +func ViewCommand(ctx context.Context) cli.Command { + commandName := policycontext.GetPrefixedCommandUsage(ctx, "view") + return cli.Command{ + Name: "view", + Usage: "view current certificate issuance policy", + UsageText: fmt.Sprintf(`**%s** +[**--provisioner**=] [**--eab-key-id**=] [**--eab-key-reference**=] +[**--admin-cert**=] [**--admin-key**=] [**--admin-subject**=] +[**--admin-provisioner**=] [**--admin-password-file**=] +[**--ca-url**=] [**--root**=] [**--context**=]`, commandName), + Description: fmt.Sprintf(`**%s** shows the currently configured policy. + +## EXAMPLES + +View the authority certificate issuance policy +''' +$ step ca policy authority view +''' + +View a provisioner certificate issuance policy +''' +$ step ca policy provisioner view --provisioner my_provisioner +''' + +View an ACME EAB certificate issuance policy by reference +''' +$ step ca policy acme view --provisioner my_acme_provisioner --eab-key-reference my_reference +''' + +View an ACME EAB certificate issuance policy by EAB Key ID +''' +$ step ca policy acme view --provisioner my_acme_provisioner --eab-key-id "lUOTGwvFQADjk8nxsVufbhyTOwrFmvO2" +'''`, commandName), + Action: command.InjectContext( + ctx, + viewAction, + ), + Flags: []cli.Flag{ + flags.Provisioner, + flags.EABKeyID, + flags.EABReference, + flags.AdminCert, + flags.AdminKey, + flags.AdminSubject, + flags.AdminProvisioner, + flags.AdminPasswordFile, + flags.CaURL, + flags.Root, + flags.Context, + }, + } +} + +func viewAction(ctx context.Context) (err error) { + var ( + provisioner = retrieveAndUnsetProvisionerFlagIfRequired(ctx) + clictx = command.CLIContextFromContext(ctx) + reference = clictx.String("eab-key-reference") + keyID = clictx.String("eab-key-id") + policy *linkedca.Policy + ) + + client, err := cautils.NewAdminClient(clictx) + if err != nil { + return fmt.Errorf("error creating admin client: %w", err) + } + + switch { + case policycontext.IsAuthorityPolicyLevel(ctx): + policy, err = client.GetAuthorityPolicy() + case policycontext.IsProvisionerPolicyLevel(ctx): + if provisioner == "" { + return errs.RequiredFlag(clictx, "provisioner") + } + policy, err = client.GetProvisionerPolicy(provisioner) + case policycontext.IsACMEPolicyLevel(ctx): + if provisioner == "" { + return errs.RequiredFlag(clictx, "provisioner") + } + if reference == "" && keyID == "" { + return errs.RequiredOrFlag(clictx, "eab-key-reference", "eab-key-id") + } + policy, err = client.GetACMEPolicy(provisioner, reference, keyID) + default: + panic("no context for policy retrieval set") + } + + if err != nil { + var ae *ca.AdminClientError + if errors.As(err, &ae) && ae.Type == "notFound" { // TODO: use constant? + fmt.Println("certificate issuance policy does not exist") + return nil + } + + return fmt.Errorf("error retrieving authority policy: %w", err) + } + + prettyPrint(policy) + + return nil +} diff --git a/command/ca/policy/actions/wildcards.go b/command/ca/policy/actions/wildcards.go new file mode 100644 index 00000000..ec2483f8 --- /dev/null +++ b/command/ca/policy/actions/wildcards.go @@ -0,0 +1,63 @@ +package actions + +import ( + "context" + "fmt" + + "github.com/smallstep/cli/internal/command" + "github.com/smallstep/cli/utils/cautils" +) + +// AllowWildcardsAction updates the policy to allow wildcard names. +func AllowWildcardsAction(ctx context.Context) (err error) { + var ( + provisioner = retrieveAndUnsetProvisionerFlagIfRequired(ctx) + clictx = command.CLIContextFromContext(ctx) + ) + + client, err := cautils.NewAdminClient(clictx) + if err != nil { + return fmt.Errorf("error creating admin client: %w", err) + } + + policy, err := retrieveAndInitializePolicy(ctx, client, provisioner) + if err != nil { + return fmt.Errorf("error retrieving policy: %w", err) + } + + policy.X509.AllowWildcardNames = true + + updatedPolicy, err := updatePolicy(ctx, client, policy, provisioner) + if err != nil { + return fmt.Errorf("error updating policy: %w", err) + } + + return prettyPrint(updatedPolicy) +} + +// DenyWildcardsAction updates the policy to deny wildcard names. +func DenyWildcardsAction(ctx context.Context) (err error) { + var ( + provisioner = retrieveAndUnsetProvisionerFlagIfRequired(ctx) + clictx = command.CLIContextFromContext(ctx) + ) + + client, err := cautils.NewAdminClient(clictx) + if err != nil { + return fmt.Errorf("error creating admin client: %w", err) + } + + policy, err := retrieveAndInitializePolicy(ctx, client, provisioner) + if err != nil { + return fmt.Errorf("error retrieving policy: %w", err) + } + + policy.X509.AllowWildcardNames = false + + updatedPolicy, err := updatePolicy(ctx, client, policy, provisioner) + if err != nil { + return fmt.Errorf("error updating policy: %w", err) + } + + return prettyPrint(updatedPolicy) +} diff --git a/command/ca/policy/authority/authority.go b/command/ca/policy/authority/authority.go new file mode 100644 index 00000000..d3de33aa --- /dev/null +++ b/command/ca/policy/authority/authority.go @@ -0,0 +1,29 @@ +package authority + +import ( + "context" + + "github.com/urfave/cli" + + "github.com/smallstep/cli/command/ca/policy/actions" + "github.com/smallstep/cli/command/ca/policy/policycontext" + "github.com/smallstep/cli/command/ca/policy/ssh" + "github.com/smallstep/cli/command/ca/policy/x509" +) + +// Command returns the authority policy subcommand. +func Command(ctx context.Context) cli.Command { + ctx = policycontext.WithAuthorityPolicyLevel(ctx) + return cli.Command{ + Name: "authority", + Usage: "manage certificate issuance policies for authorities", + UsageText: "**step ca policy authority** [arguments] [global-flags] [subcommand-flags]", + Description: `**step ca policy authority** command group provides facilities for managing certificate issuance policies for authorities.`, + Subcommands: cli.Commands{ + actions.ViewCommand(ctx), + actions.RemoveCommand(ctx), + x509.Command(ctx), + ssh.Command(ctx), + }, + } +} diff --git a/command/ca/policy/policy.go b/command/ca/policy/policy.go new file mode 100644 index 00000000..952f860b --- /dev/null +++ b/command/ca/policy/policy.go @@ -0,0 +1,27 @@ +package policy + +import ( + "context" + + "github.com/urfave/cli" + + "github.com/smallstep/cli/command/ca/policy/acme" + "github.com/smallstep/cli/command/ca/policy/authority" + "github.com/smallstep/cli/command/ca/policy/provisioner" +) + +// Command returns the policy subcommand. +func Command() cli.Command { + ctx := context.Background() + return cli.Command{ + Name: "policy", + Usage: "manage certificate issuance policies", + UsageText: "**step ca policy** [arguments] [global-flags] [subcommand-flags]", + Description: `**step ca policy** command group provides facilities for managing certificate issuance policies.`, + Subcommands: cli.Commands{ + authority.Command(ctx), + provisioner.Command(ctx), + acme.Command(ctx), + }, + } +} diff --git a/command/ca/policy/policycontext/context.go b/command/ca/policy/policycontext/context.go new file mode 100644 index 00000000..2dcb761f --- /dev/null +++ b/command/ca/policy/policycontext/context.go @@ -0,0 +1,168 @@ +package policycontext + +import ( + "context" +) + +type policyLevelContextKey struct{} + +type policyLevel int + +const ( + authorityPolicyLevel policyLevel = iota + 1 + provisionerPolicyLevel + acmePolicyLevel +) + +// WithAuthorityPolicyLevel returns a new context.Context with +// parent ctx and authority policy level set. +func WithAuthorityPolicyLevel(ctx context.Context) context.Context { + return context.WithValue(ctx, policyLevelContextKey{}, authorityPolicyLevel) +} + +// IsAuthorityPolicyLevel returns if the context.Context has authority policy level. +func IsAuthorityPolicyLevel(ctx context.Context) bool { + return isPolicyLevel(ctx, authorityPolicyLevel) +} + +// WithProvisionerPolicyLevel returns a new context.Context with +// parent ctx and provisioner policy level set. +func WithProvisionerPolicyLevel(ctx context.Context) context.Context { + return context.WithValue(ctx, policyLevelContextKey{}, provisionerPolicyLevel) +} + +// IsProvisionerPolicyLevel returns if the context.Context has provisioner policy level. +func IsProvisionerPolicyLevel(ctx context.Context) bool { + return isPolicyLevel(ctx, provisionerPolicyLevel) +} + +// WithACMEPolicyLevel returns a new context.Context with +// parent ctx and ACME account policy level set. +func WithACMEPolicyLevel(ctx context.Context) context.Context { + return context.WithValue(ctx, policyLevelContextKey{}, acmePolicyLevel) +} + +// IsACMEPolicyLevel returns if the context.Context has ACME account policy level. +func IsACMEPolicyLevel(ctx context.Context) bool { + return isPolicyLevel(ctx, acmePolicyLevel) +} + +// isPolicyLevel checks if the context.Context has the specified policy level set. +func isPolicyLevel(ctx context.Context, level policyLevel) bool { + v, _ := ctx.Value(policyLevelContextKey{}).(policyLevel) + return v == level +} + +type policyConfigurationTypeContextKey struct{} + +type policyConfigurationType int + +const ( + x509PolicyType policyConfigurationType = iota + 1 + sshHostPolicyType + sshUserPolicyType +) + +// WithX509Policy returns a new context.Context with +// parent ctx and X509 policy set. +func WithX509Policy(ctx context.Context) context.Context { + return context.WithValue(ctx, policyConfigurationTypeContextKey{}, x509PolicyType) +} + +// IsX509Policy returns if the context.Context has X.509 policy set. +func IsX509Policy(ctx context.Context) bool { + v, _ := ctx.Value(policyConfigurationTypeContextKey{}).(policyConfigurationType) + return v == x509PolicyType +} + +// WithSSHHostPolicy returns a context.Context with SSH host policy set. +func WithSSHHostPolicy(ctx context.Context) context.Context { + return context.WithValue(ctx, policyConfigurationTypeContextKey{}, sshHostPolicyType) +} + +// IsSSHHostPolicy returns if the context.Context has SSH host policy set. +func IsSSHHostPolicy(ctx context.Context) bool { + v, _ := ctx.Value(policyConfigurationTypeContextKey{}).(policyConfigurationType) + return v == sshHostPolicyType +} + +// WithSSHUserPolicy returns a context.Context with SSH user policy set. +func WithSSHUserPolicy(ctx context.Context) context.Context { + return context.WithValue(ctx, policyConfigurationTypeContextKey{}, sshUserPolicyType) +} + +// IsSSHUserPolicy returns if context.Context has SSH user policy set. +func IsSSHUserPolicy(ctx context.Context) bool { + v, _ := ctx.Value(policyConfigurationTypeContextKey{}).(policyConfigurationType) + return v == sshUserPolicyType +} + +type policyTypeContextKey struct{} + +type policyType int + +const ( + allowType policyType = iota + 1 + denyType +) + +// WithAllow returns a context.Context with allow policy set. +func WithAllow(ctx context.Context) context.Context { + return context.WithValue(ctx, policyTypeContextKey{}, allowType) +} + +// IsAllow returns if the context.Context has allow set. +func IsAllow(ctx context.Context) bool { + v, _ := ctx.Value(policyTypeContextKey{}).(policyType) + return v == allowType +} + +// WithDeny returns a context.Context with deny set. +func WithDeny(ctx context.Context) context.Context { + return context.WithValue(ctx, policyTypeContextKey{}, denyType) +} + +// IsDeny returns if context.Context has deny set. +func IsDeny(ctx context.Context) bool { + v, _ := ctx.Value(policyTypeContextKey{}).(policyType) + return v == denyType +} + +func GetPrefixedCommandUsage(ctx context.Context, commandName string) string { + usage := "step ca policy" + + switch { + case IsAuthorityPolicyLevel(ctx): + usage += " authority" + case IsProvisionerPolicyLevel(ctx): + usage += " provisioner" + case IsACMEPolicyLevel(ctx): + usage += " acme" + default: + panic("no policy level set") + } + + switch { + case IsX509Policy(ctx): + usage += " x509" + case IsSSHHostPolicy(ctx): + usage += " ssh host" + case IsSSHUserPolicy(ctx): + usage += " ssh user" + default: + // noop; not every command using policycontext needs this to be set + break + } + + switch { + case IsAllow(ctx): + usage += " allow" + case IsDeny(ctx): + usage += " deny" + default: + // noop; not every command using policycontext needs this to be set + break + } + + return usage + " " + commandName +} diff --git a/command/ca/policy/policycontext/context_test.go b/command/ca/policy/policycontext/context_test.go new file mode 100644 index 00000000..2f2aa571 --- /dev/null +++ b/command/ca/policy/policycontext/context_test.go @@ -0,0 +1,58 @@ +package policycontext + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_hasPolicyLevel(t *testing.T) { + type args struct { + ctx context.Context + level policyLevel + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "not-set", + args: args{ + ctx: context.Background(), + level: authorityPolicyLevel, + }, + want: false, + }, + { + name: "false", + args: args{ + ctx: WithAuthorityPolicyLevel(context.Background()), + level: provisionerPolicyLevel, + }, + want: false, + }, + { + name: "true", + args: args{ + ctx: WithAuthorityPolicyLevel(context.Background()), + level: authorityPolicyLevel, + }, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isPolicyLevel(tt.args.ctx, tt.args.level); got != tt.want { + t.Errorf("hasPolicyLevel() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_hasPolicyLevelPanics(t *testing.T) { + t.Parallel() + //nolint:staticcheck // explicit test for a nil context + assert.Panics(t, func() { isPolicyLevel(nil, authorityPolicyLevel) }) +} diff --git a/command/ca/policy/provisioner/provisioner.go b/command/ca/policy/provisioner/provisioner.go new file mode 100644 index 00000000..6a80efd6 --- /dev/null +++ b/command/ca/policy/provisioner/provisioner.go @@ -0,0 +1,33 @@ +package provisioner + +import ( + "context" + + "github.com/urfave/cli" + + "github.com/smallstep/cli/command/ca/policy/actions" + "github.com/smallstep/cli/command/ca/policy/policycontext" + "github.com/smallstep/cli/command/ca/policy/ssh" + "github.com/smallstep/cli/command/ca/policy/x509" +) + +// Command returns the policy subcommand. +func Command(ctx context.Context) cli.Command { + ctx = policycontext.WithProvisionerPolicyLevel(ctx) + return cli.Command{ + Name: "provisioner", + Usage: "manage certificate issuance policies for provisioners", + UsageText: "**step ca policy provisioner** [arguments] [global-flags] [subcommand-flags]", + Description: `**step ca policy provisioner** command group provides facilities for managing certificate issuance policies for provisioners. + +Please note that certificate issuance policies on the provisioner level are currently only supported in Certificate Manager: https://u.step.sm/cm. + +`, + Subcommands: cli.Commands{ + actions.ViewCommand(ctx), + actions.RemoveCommand(ctx), + x509.Command(ctx), + ssh.Command(ctx), + }, + } +} diff --git a/command/ca/policy/ssh/host/allow.go b/command/ca/policy/ssh/host/allow.go new file mode 100644 index 00000000..ec6e70c7 --- /dev/null +++ b/command/ca/policy/ssh/host/allow.go @@ -0,0 +1,26 @@ +package host + +import ( + "context" + + "github.com/urfave/cli" + + "github.com/smallstep/cli/command/ca/policy/actions" + "github.com/smallstep/cli/command/ca/policy/policycontext" +) + +// Command returns the SSH host allow subcommand. +func allowCommand(ctx context.Context) cli.Command { + ctx = policycontext.WithAllow(ctx) + return cli.Command{ + Name: "allow", + Usage: "manage allowed SSH host certificate principals", + UsageText: "**step ca policy ssh host allow** [arguments] [global-flags] [subcommand-flags]", + Description: `**step ca policy ssh host allow** command group provides facilities for managing SSH host certificate principals to be allowed.`, + Subcommands: cli.Commands{ + actions.DNSCommand(ctx), + actions.EmailCommand(ctx), + actions.PrincipalsCommand(ctx), + }, + } +} diff --git a/command/ca/policy/ssh/host/deny.go b/command/ca/policy/ssh/host/deny.go new file mode 100644 index 00000000..2277c806 --- /dev/null +++ b/command/ca/policy/ssh/host/deny.go @@ -0,0 +1,26 @@ +package host + +import ( + "context" + + "github.com/urfave/cli" + + "github.com/smallstep/cli/command/ca/policy/actions" + "github.com/smallstep/cli/command/ca/policy/policycontext" +) + +// Command returns the SSH host deny subcommand. +func denyCommand(ctx context.Context) cli.Command { + ctx = policycontext.WithDeny(ctx) + return cli.Command{ + Name: "deny", + Usage: "manage denied dSSH host certificate principals", + UsageText: "**step ca policy ssh host deny** [arguments] [global-flags] [subcommand-flags]", + Description: `**step ca policy ssh host deny** command group provides facilities for managing SSH host certificate principals to be denied.`, + Subcommands: cli.Commands{ + actions.DNSCommand(ctx), + actions.EmailCommand(ctx), + actions.PrincipalsCommand(ctx), + }, + } +} diff --git a/command/ca/policy/ssh/host/host.go b/command/ca/policy/ssh/host/host.go new file mode 100644 index 00000000..d03b6eb0 --- /dev/null +++ b/command/ca/policy/ssh/host/host.go @@ -0,0 +1,24 @@ +package host + +import ( + "context" + + "github.com/urfave/cli" + + "github.com/smallstep/cli/command/ca/policy/policycontext" +) + +// Command returns the SSH host policy subcommand. +func Command(ctx context.Context) cli.Command { + ctx = policycontext.WithSSHHostPolicy(ctx) + return cli.Command{ + Name: "host", + Usage: "manage SSH host certificate issuance policies", + UsageText: "**step ca policy ssh host** [arguments] [global-flags] [subcommand-flags]", + Description: `**step ca policy ssh host** command group provides facilities for managing SSH host certificate issuance policies.`, + Subcommands: cli.Commands{ + allowCommand(ctx), + denyCommand(ctx), + }, + } +} diff --git a/command/ca/policy/ssh/ssh.go b/command/ca/policy/ssh/ssh.go new file mode 100644 index 00000000..bcb19aa5 --- /dev/null +++ b/command/ca/policy/ssh/ssh.go @@ -0,0 +1,24 @@ +package ssh + +import ( + "context" + + "github.com/urfave/cli" + + "github.com/smallstep/cli/command/ca/policy/ssh/host" + "github.com/smallstep/cli/command/ca/policy/ssh/user" +) + +// Command returns the ssh subcommand. +func Command(ctx context.Context) cli.Command { + return cli.Command{ + Name: "ssh", + Usage: "manage SSH certificate issuance policies", + UsageText: "**step ca policy ssh** [arguments] [global-flags] [subcommand-flags]", + Description: `**step ca policy ssh** command group provides facilities for managing SSH certificate issuance policies.`, + Subcommands: cli.Commands{ + host.Command(ctx), + user.Command(ctx), + }, + } +} diff --git a/command/ca/policy/ssh/user/allow.go b/command/ca/policy/ssh/user/allow.go new file mode 100644 index 00000000..64015094 --- /dev/null +++ b/command/ca/policy/ssh/user/allow.go @@ -0,0 +1,25 @@ +package user + +import ( + "context" + + "github.com/urfave/cli" + + "github.com/smallstep/cli/command/ca/policy/actions" + "github.com/smallstep/cli/command/ca/policy/policycontext" +) + +// allowCommand returns the SSH user allow subcommand. +func allowCommand(ctx context.Context) cli.Command { + ctx = policycontext.WithAllow(ctx) + return cli.Command{ + Name: "allow", + Usage: "manage allowed SSH user certificate principals", + UsageText: "**step ca policy ssh user allow** [arguments] [global-flags] [subcommand-flags]", + Description: `**step ca policy ssh user allow** command group provides facilities for managing SSH user certificate principals to be allowed.`, + Subcommands: cli.Commands{ + actions.EmailCommand(ctx), + actions.PrincipalsCommand(ctx), + }, + } +} diff --git a/command/ca/policy/ssh/user/deny.go b/command/ca/policy/ssh/user/deny.go new file mode 100644 index 00000000..9697d0fd --- /dev/null +++ b/command/ca/policy/ssh/user/deny.go @@ -0,0 +1,25 @@ +package user + +import ( + "context" + + "github.com/urfave/cli" + + "github.com/smallstep/cli/command/ca/policy/actions" + "github.com/smallstep/cli/command/ca/policy/policycontext" +) + +// denyCommand returns the SSH user deny subcommand. +func denyCommand(ctx context.Context) cli.Command { + ctx = policycontext.WithDeny(ctx) + return cli.Command{ + Name: "deny", + Usage: "manage denied SSH user certificate principals", + UsageText: "**step ca policy ssh user deny** [arguments] [global-flags] [subcommand-flags]", + Description: `**step ca policy ssh user deny** command group provides facilities for managing SSH user certificate principals to be denied.`, + Subcommands: cli.Commands{ + actions.EmailCommand(ctx), + actions.PrincipalsCommand(ctx), + }, + } +} diff --git a/command/ca/policy/ssh/user/user.go b/command/ca/policy/ssh/user/user.go new file mode 100644 index 00000000..22a597c1 --- /dev/null +++ b/command/ca/policy/ssh/user/user.go @@ -0,0 +1,24 @@ +package user + +import ( + "context" + + "github.com/urfave/cli" + + "github.com/smallstep/cli/command/ca/policy/policycontext" +) + +// Command returns the SSH user policy subcommand. +func Command(ctx context.Context) cli.Command { + ctx = policycontext.WithSSHUserPolicy(ctx) + return cli.Command{ + Name: "user", + Usage: "manage SSH user certificate issuance policies", + UsageText: "**step ca policy ssh user** [arguments] [global-flags] [subcommand-flags]", + Description: `**step ca policy ssh user** command group provides facilities for managing SSH user certificate issuance policies.`, + Subcommands: cli.Commands{ + allowCommand(ctx), + denyCommand(ctx), + }, + } +} diff --git a/command/ca/policy/x509/allow.go b/command/ca/policy/x509/allow.go new file mode 100644 index 00000000..6b10d0d2 --- /dev/null +++ b/command/ca/policy/x509/allow.go @@ -0,0 +1,28 @@ +package x509 + +import ( + "context" + + "github.com/urfave/cli" + + "github.com/smallstep/cli/command/ca/policy/actions" + "github.com/smallstep/cli/command/ca/policy/policycontext" +) + +// allowCommand returns the allow subcommand. +func allowCommand(ctx context.Context) cli.Command { + ctx = policycontext.WithAllow(ctx) + return cli.Command{ + Name: "allow", + Usage: "manage allowed names for X.509 certificate issuance policies", + UsageText: "**step ca policy x509 allow** [arguments] [global-flags] [subcommand-flags]", + Description: `**step ca policy x509 allow** command group provides facilities for managing X.509 names to be allowed.`, + Subcommands: cli.Commands{ + actions.CommonNamesCommand(ctx), + actions.DNSCommand(ctx), + actions.EmailCommand(ctx), + actions.IPCommand(ctx), + actions.URICommand(ctx), + }, + } +} diff --git a/command/ca/policy/x509/deny.go b/command/ca/policy/x509/deny.go new file mode 100644 index 00000000..b6742a2d --- /dev/null +++ b/command/ca/policy/x509/deny.go @@ -0,0 +1,28 @@ +package x509 + +import ( + "context" + + "github.com/urfave/cli" + + "github.com/smallstep/cli/command/ca/policy/actions" + "github.com/smallstep/cli/command/ca/policy/policycontext" +) + +// Command returns the deny subcommand. +func denyCommand(ctx context.Context) cli.Command { + ctx = policycontext.WithDeny(ctx) + return cli.Command{ + Name: "deny", + Usage: "manage denied names for X.509 certificate issuance policies", + UsageText: "**step ca policy x509 deny** [arguments] [global-flags] [subcommand-flags]", + Description: `**step ca policy x509 deny** command group provides facilities for managing X.509 names to be denied.`, + Subcommands: cli.Commands{ + actions.CommonNamesCommand(ctx), + actions.DNSCommand(ctx), + actions.EmailCommand(ctx), + actions.IPCommand(ctx), + actions.URICommand(ctx), + }, + } +} diff --git a/command/ca/policy/x509/wildcards.go b/command/ca/policy/x509/wildcards.go new file mode 100644 index 00000000..108d4945 --- /dev/null +++ b/command/ca/policy/x509/wildcards.go @@ -0,0 +1,122 @@ +package x509 + +import ( + "context" + + "github.com/smallstep/cli/command/ca/policy/actions" + "github.com/smallstep/cli/flags" + "github.com/smallstep/cli/internal/command" + "github.com/urfave/cli" +) + +var provisionerFilterFlag = cli.StringFlag{ + Name: "provisioner", + Usage: `The provisioner `, +} + +func wildcardsCommand(ctx context.Context) cli.Command { + return cli.Command{ + Name: "wildcards", + Usage: "manage wildcard name settings for X.509 certificate issuance policies", + UsageText: `**step ca policy x509 wildcards**`, + Description: `**step ca policy x509 wildcards** command group provides facilities for managing X.509 wildcard names.`, + Subcommands: cli.Commands{ + allowWildcardsCommand(ctx), + denyWildcardsCommand(ctx), + }, + } +} + +func allowWildcardsCommand(ctx context.Context) cli.Command { + return cli.Command{ + Name: "allow", + Usage: "allow wildcard names in X.509 certificate issuance policies", + UsageText: `**step ca policy x509 wildcards allow** +[**--provisioner**=] [**--eab-key-id**=] [**--eab-key-reference**=] +[**--admin-cert**=] [**--admin-key**=] [**--admin-subject**=] +[**--admin-provisioner**=] [**--admin-password-file**=] +[**--ca-url**=] [**--root**=] [**--context**=]`, + Description: `**step ca policy x509 wildcards allow** allow wildcard names in X.509 policy + +## EXAMPLES + +Allow wildcard names in X.509 certificates on authority level +''' +$ step ca policy authority x509 wildcards allow +''' + +Allow wildcard names in X.509 certificates on provisioner level +''' +$ step ca policy provisioner x509 wildcards allow --provisioner my_provisioner +''' + +Allow wildcard names in X.509 certificates on ACME account level by reference +''' +$ step ca policy acme x509 wildcards allow --provisioner my_acme_provisioner --eab-reference my_reference +'''`, + Action: command.InjectContext( + ctx, + actions.AllowWildcardsAction, + ), + Flags: []cli.Flag{ + provisionerFilterFlag, + flags.EABKeyID, + flags.EABReference, + flags.AdminCert, + flags.AdminKey, + flags.AdminSubject, + flags.AdminProvisioner, + flags.AdminPasswordFile, + flags.CaURL, + flags.Root, + flags.Context, + }, + } +} + +func denyWildcardsCommand(ctx context.Context) cli.Command { + return cli.Command{ + Name: "deny", + Usage: "deny wildcard names in X.509 certificate issuance policies", + UsageText: `**step ca policy x509 wildcards deny** +[**--provisioner**=] [**--eab-key-id**=] [**--eab-key-reference**=] +[**--admin-cert**=] [**--admin-key**=] [**--admin-subject**=] +[**--admin-provisioner**=] [**--admin-password-file**=] +[**--ca-url**=] [**--root**=] [**--context**=]`, + Description: `**step ca policy x509 wildcards deny** deny wildcard names in X.509 policy + +## EXAMPLES + +Deny wildcard names in X.509 certificates on authority level +''' +$ step ca policy authority x509 wildcards deny +''' + +Deny wildcard names in X.509 certificates on provisioner level +''' +$ step ca policy provisioner x509 wildcards deny --provisioner my_provisioner +''' + +Deny wildcard names in X.509 certificates on ACME account level by reference +''' +$ step ca policy acme x509 wildcards deny --provisioner my_acme_provisioner --eab-reference my_reference +'''`, + Action: command.InjectContext( + ctx, + actions.DenyWildcardsAction, + ), + Flags: []cli.Flag{ + provisionerFilterFlag, + flags.EABKeyID, + flags.EABReference, + flags.AdminCert, + flags.AdminKey, + flags.AdminSubject, + flags.AdminProvisioner, + flags.AdminPasswordFile, + flags.CaURL, + flags.Root, + flags.Context, + }, + } +} diff --git a/command/ca/policy/x509/x509.go b/command/ca/policy/x509/x509.go new file mode 100644 index 00000000..0bb0dbb3 --- /dev/null +++ b/command/ca/policy/x509/x509.go @@ -0,0 +1,25 @@ +package x509 + +import ( + "context" + + "github.com/urfave/cli" + + "github.com/smallstep/cli/command/ca/policy/policycontext" +) + +// Command returns the x509 subcommand. +func Command(ctx context.Context) cli.Command { + ctx = policycontext.WithX509Policy(ctx) + return cli.Command{ + Name: "x509", + Usage: "manage X.509 certificate issuance policies", + UsageText: `**step ca policy x509** [arguments] [global-flags] [subcommand-flags]`, + Description: `**step ca policy x509** command group provides facilities for managing X.509 certificate issuance policies.`, + Subcommands: cli.Commands{ + allowCommand(ctx), + denyCommand(ctx), + wildcardsCommand(ctx), + }, + } +} diff --git a/command/ca/provisioner/add.go b/command/ca/provisioner/add.go index 898b3ddf..a6b081a7 100644 --- a/command/ca/provisioner/add.go +++ b/command/ca/provisioner/add.go @@ -1,433 +1,515 @@ package provisioner import ( + "bytes" "crypto/ecdsa" "crypto/ed25519" "crypto/rsa" "crypto/x509" "encoding/pem" + "fmt" "net/url" "os" "strings" "github.com/pkg/errors" - "github.com/smallstep/certificates/authority/config" - "github.com/smallstep/certificates/authority/provisioner" - "github.com/smallstep/cli/crypto/pemutil" + "github.com/urfave/cli" + + "github.com/smallstep/cli-utils/errs" + "github.com/smallstep/cli-utils/ui" + "github.com/smallstep/linkedca" + "go.step.sm/crypto/jose" + "go.step.sm/crypto/pemutil" + "github.com/smallstep/cli/flags" - "github.com/smallstep/cli/jose" - "github.com/smallstep/cli/ui" + "github.com/smallstep/cli/internal/cast" + "github.com/smallstep/cli/internal/sliceutil" "github.com/smallstep/cli/utils" - "github.com/urfave/cli" - "go.step.sm/cli-utils/errs" ) func addCommand() cli.Command { - return cli.Command{ + return cli.Command{ // #nosec G101 -- Google OIDC example values Name: "add", Action: cli.ActionFunc(addAction), - Usage: "add one or more provisioners to the CA configuration", - UsageText: `**step ca provisioner add** [ ...] -**--ca-config**= [**--type**=JWK] [**--create**] [**--password-file**=] - -**step ca provisioner add** **--type**=OIDC **--ca-config**= + Usage: "add a provisioner", + UsageText: `**step ca provisioner add** **--type**=JWK [**--public-key**=] +[**--private-key**=] [**--create**] [**--password-file**=] +[**--admin-cert**=] [**--admin-key**=] +[**--admin-subject**=] [**--admin-provisioner**=] [**--admin-password-file**=] +[**--ca-url**=] [**--root**=] [**--context**=] [**--ca-config**=] +[**--x509-template**=] [**--x509-template-data**=] [**--ssh-template**=] +[**--ssh-template-data**=] + +ACME + +**step ca provisioner add** **--type**=ACME +[**--force-cn**] [**--require-eab**] [**--challenge**=] +[**--attestation-format**=] [**--attestation-roots**=] +[**--admin-cert**=] [**--admin-key**=] +[**--admin-subject**=] [**--admin-provisioner**=] [**--admin-password-file**=] +[**--ca-url**=] [**--root**=] [**--context**=] [**--ca-config**=] +[**--x509-template**=] [**--x509-template-data**=] + +OIDC + +**step ca provisioner add** **--type**=OIDC [**--client-id**=] [**--client-secret**=] [**--configuration-endpoint**=] [**--domain**=] [**--admin**=]... +[**--admin-cert**=] [**--admin-key**=] +[**--admin-subject**=] [**--admin-provisioner**=] [**--admin-password-file**=] +[**--ca-url**=] [**--root**=] [**--context**=] [**--ca-config**=] +[**--x509-template**=] [**--x509-template-data**=] [**--ssh-template**=] +[**--ssh-template-data**=] -**step ca provisioner add** **--type**=x5c **--x5c-root**= -[**--ca-config**=]... - -**step ca provisioner add** **--type**=k8sSA -[**--pem-keys=**] [**--ca-config**=]... - -**step ca provisioner add** **--type**=[AWS|Azure|GCP] -[**--ca-config**=] [**--aws-account**=] -[**--gcp-service-account**=] [**--gcp-project**=] -[**--azure-tenant**=] [**--azure-resource-group**=] -[**--instance-age**=] [**--iid-roots**=] -[**--disable-custom-sans**] [**--disable-trust-on-first-use**] - -**step ca provisioner add** **--type**=ACME **--ca-config**=`, - Flags: []cli.Flag{ - flags.CaConfig, - cli.StringFlag{ - Name: "type", - Value: provisioner.TypeJWK.String(), - Usage: `The of provisioner to create. +X5C -: is a case-insensitive string and must be one of: +**step ca provisioner add** **--type**=X5C **--x5c-roots**= +[**--admin-cert**=] [**--admin-key**=] +[**--admin-subject**=] [**--admin-provisioner**=] [**--admin-password-file**=] +[**--ca-url**=] [**--root**=] [**--context**=] [**--ca-config**=] +[**--x509-template**=] [**--x509-template-data**=] [**--ssh-template**=] +[**--ssh-template-data**=] - **JWK** - : Uses an JWK key pair to sign provisioning tokens. (default) +SSHPOP - **OIDC** - : Uses an OpenID Connect provider to sign provisioning tokens. +**step ca provisioner add** **--type**=SSHPOP +[**--admin-cert**=] [**--admin-key**=] +[**--admin-subject**=] [**--admin-provisioner**=] [**--admin-password-file**=] +[**--ca-url**=] [**--root**=] [**--context**=] [**--ca-config**=] - **AWS** - : Uses Amazon AWS instance identity documents. +Nebula - **GCP** - : Use Google instance identity tokens. +**step ca provisioner add** **--type**=Nebula **--nebula-root**= +[**--admin-cert**=] [**--admin-key**=] +[**--admin-subject**=] [**--admin-provisioner**=] [**--admin-password-file**=] +[**--ca-url**=] [**--root**=] [**--context**=] [**--ca-config**=] - **Azure** - : Uses Microsoft Azure identity tokens. +K8SSA (Kubernetes Service Account) - **ACME** - : Uses the ACME protocol to create certificates. +**step ca provisioner add** **--type**=K8SSA [**--public-key**=] +[**--admin-cert**=] [**--admin-key**=] +[**--admin-subject**=] [**--admin-provisioner**=] [**--admin-password-file**=] +[**--ca-url**=] [**--root**=] [**--context**=] [**--ca-config**=] +[**--x509-template**=] [**--x509-template-data**=] - **X5C** - : Uses an X509 Certificate / private key pair to sign provisioning tokens. +IID (AWS/GCP/Azure) - **K8sSA** - : Uses Kubernetes Service Account tokens. +**step ca provisioner add** **--type**=[AWS|Azure|GCP] +[**--aws-account**=] +[**--gcp-service-account**=] [**--gcp-project**=] [**--gcp-organization**=] +[**--azure-tenant**=] [**--azure-resource-group**=] +[**--azure-audience**=] [**--azure-subscription-id**=] +[**--azure-object-id**=] [**--instance-age**=] [**--iid-roots**=] +[**--disable-custom-sans**] [**--disable-trust-on-first-use**] +[**--disable-ssh-ca-user**] [**--disable-ssh-ca-host**] +[**--admin-cert**=] [**--admin-key**=] +[**--admin-subject**=] [**--admin-provisioner**=] [**--admin-password-file**=] +[**--ca-url**=] [**--root**=] [**--context**=] [**--ca-config**=] +[**--x509-template**=] [**--x509-template-data**=] [**--ssh-template**=] +[**--ssh-template-data**=] + +SCEP + +**step ca provisioner add** **--type**=SCEP [**--force-cn**] [**--challenge**=] +[**--capabilities**=] [**--include-root**] [**--exclude-intermediate**] +[**--min-public-key-length**=] [**--encryption-algorithm-identifier**=] +[**--scep-decrypter-certificate-file**=] [**--scep-decrypter-key-file**=] +[**--scep-decrypter-key-uri**=] [**--scep-decrypter-key-password-file**=] +[**--admin-cert**=] [**--admin-key**=] [**--admin-subject**=] +[**--admin-provisioner**=] [**--admin-password-file**=] +[**--ca-url**=] [**--root**=] [**--context**=] [**--ca-config**=] +[**--x509-template**=] [**--x509-template-data**=]`, + Flags: []cli.Flag{ + // General provisioner flags + typeFlag, + pubKeyFlag, - **SSHPOP** - : Uses an SSH Certificate / private key pair to sign provisioning tokens.`, - }, - flags.PasswordFile, - cli.BoolFlag{ - Name: "create", - Usage: `Create a new ECDSA key pair using curve P-256 and populate a new JWK -provisioner with it.`, - }, - cli.BoolFlag{ - Name: "ssh", - Usage: `Enable SSH on the new provisioners.`, - }, + // JWK provisioner flags + jwkCreateFlag, + jwkPrivKeyFlag, // OIDC provisioner flags - cli.StringFlag{ - Name: "client-id", - Usage: `The used to validate the audience in an OpenID Connect token.`, - }, - cli.StringFlag{ - Name: "client-secret", - Usage: `The used to obtain the OpenID Connect tokens.`, - }, - cli.StringFlag{ - Name: "listen-address", - Usage: `The callback
used in the OpenID Connect flow (e.g. \":10000\")`, - }, - cli.StringFlag{ - Name: "configuration-endpoint", - Usage: `OpenID Connect configuration .`, - }, - cli.StringSliceFlag{ - Name: "admin", - Usage: `The of an admin user in an OpenID Connect provisioner, this user -will not have restrictions in the certificates to sign. Use the -'--admin' flag multiple times to configure multiple administrators.`, - }, - cli.StringSliceFlag{ - Name: "domain", - Usage: `The used to validate the email claim in an OpenID Connect provisioner. -Use the '--domain' flag multiple times to configure multiple domains.`, - }, - - // Cloud provisioner flags - cli.StringSliceFlag{ - Name: "aws-account", - Usage: `The AWS account used to validate the identity documents. -Use the flag multiple times to configure multiple accounts.`, - }, - cli.StringFlag{ - Name: "azure-tenant", - Usage: `The Microsoft Azure tenant used to validate the identity tokens.`, - }, - cli.StringSliceFlag{ - Name: "azure-resource-group", - Usage: `The Microsoft Azure resource group used to validate the identity tokens. -Use the flag multipl etimes to configure multiple resource groups`, - }, - cli.StringSliceFlag{ - Name: "gcp-service-account", - Usage: `The Google service account or used to validate the identity tokens. -Use the flag multiple times to configure multiple service accounts.`, - }, - cli.StringSliceFlag{ - Name: "gcp-project", - Usage: `The Google project used to validate the identity tokens. -Use the flag multipl etimes to configure multiple projects`, - }, - cli.DurationFlag{ - Name: "instance-age", - Usage: `The maximum to grant a certificate in AWS and GCP provisioners. -A is sequence of decimal numbers, each with optional fraction and a -unit suffix, such as "300ms", "-1.5h" or "2h45m". Valid time units are "ns", -"us" (or "μs"), "ms", "s", "m", "h".`, - }, - cli.StringFlag{ - Name: "iid-roots", - Usage: `The containing the certificates used to validate the -instance identity documents in AWS.`, - }, - cli.BoolFlag{ - Name: "disable-custom-sans", - Usage: `On cloud provisioners, if enabled only the internal DNS and IP will be added as a SAN. -By default it will accept any SAN in the CSR.`, - }, - cli.BoolFlag{ - Name: "disable-trust-on-first-use,disable-tofu", - Usage: `On cloud provisioners, if enabled multiple sign request for this provisioner -with the same instance will be accepted. By default only the first request -will be accepted.`, - }, + oidcClientIDFlag, + oidcClientSecretFlag, + oidcListenAddressFlag, + oidcConfigEndpointFlag, + oidcAdminFlag, + oidcDomainFlag, + oidcGroupFlag, + oidcTenantIDFlag, // X5C provisioner flags - cli.StringFlag{ - Name: "x5c-root", - Usage: `Root certificate (chain) used to validate the signature on X5C -provisioning tokens.`, - }, - // K8sSA provisioner flags - cli.StringFlag{ - Name: "pem-keys", - Usage: `Public key for validating signatures on K8s Service Account Tokens. -PEM formatted bundle (can have multiple PEM blocks in the same file) of public -keys and x509 Certificates.`, - }, - }, - Description: `**step ca provisioner add** adds one or more provisioners -to the configuration and writes the new configuration back to the CA config. + x5cRootsFlag, + + // Nebula provisioner flags + nebulaRootFlag, + + // ACME provisioner flags + requireEABFlag, // ACME + forceCNFlag, // ACME + SCEP + challengeFlag, // ACME + SCEP + attestationFormatFlag, // ACME + attestationRootsFlag, // ACME + + // SCEP provisioner flags + scepCapabilitiesFlag, + scepIncludeRootFlag, + scepExcludeIntermediateFlag, + scepMinimumPublicKeyLengthFlag, + scepEncryptionAlgorithmIdentifierFlag, + scepDecrypterCertFileFlag, + scepDecrypterKeyFileFlag, + scepDecrypterKeyURIFlag, + scepDecrypterKeyPasswordFileFlag, -To pick up the new configuration you must SIGHUP (kill -1 ) or restart the -step-ca process. + // Cloud provisioner flags + awsAccountFlag, + azureTenantFlag, + azureResourceGroupFlag, + azureAudienceFlag, + azureSubscriptionIDFlag, + azureObjectIDFlag, + gcpServiceAccountFlag, + gcpProjectFlag, + gcpOrganizationFlag, + instanceAgeFlag, + disableCustomSANsFlag, + disableTOFUFlag, + disableSSHCAUserFlag, + disableSSHCAHostFlag, + + // Claims + x509TemplateFlag, + x509TemplateDataFlag, + sshTemplateFlag, + sshTemplateDataFlag, + x509MinDurFlag, + x509MaxDurFlag, + x509DefaultDurFlag, + sshUserMinDurFlag, + sshUserMaxDurFlag, + sshUserDefaultDurFlag, + sshHostMinDurFlag, + sshHostMaxDurFlag, + sshHostDefaultDurFlag, + disableRenewalFlag, + allowRenewalAfterExpiryFlag, + disableSmallstepExtensionsFlag, + //enableX509Flag, + enableSSHFlag, + + flags.AdminCert, + flags.AdminKey, + flags.AdminSubject, + flags.AdminProvisioner, + flags.AdminPasswordFileNoAlias, + flags.PasswordFile, + flags.CaURL, + flags.Root, + flags.Context, + flags.CaConfig, + }, + Description: `**step ca provisioner add** adds a provisioner to the CA configuration. ## POSITIONAL ARGUMENTS -: The name of the provisioners, if a list of JWK files are passed, this name -will be linked to all the keys. - - -: List of private (or public) keys in JWK or PEM format. +: The name of the provisioner. ## EXAMPLES -Add a single JWK provisioner: +Create a JWK provisioner with newly generated keys and a template for x509 certificates: ''' -$ step ca provisioner add max@smallstep.com ./max-laptop.jwk --ca-config ca.json +step ca provisioner add cicd --type JWK --create --x509-template ./templates/example.tpl ''' -Add a single JWK provisioner using an auto-generated asymmetric key pair: +Create a JWK provisioner and explicitly select the configuration file to update: ''' -$ step ca provisioner add max@smallstep.com --ca-config ca.json \ ---create +step ca provisioner add cicd --type JWK --create --ca-config /path/to/ca.json ''' -Add a single JWK provisioner with ssh enabled: +Create a JWK provisioner with duration claims: ''' -$ step ca provisioner add max@smallstep.com --ca-config ca.json --ssh --create +step ca provisioner add cicd --type JWK --create --x509-min-dur 20m --x509-default-dur 48h --ssh-user-min-dur 17m --ssh-host-default-dur 16h ''' -Add a list of provisioners for a single name: +Create a JWK provisioner with existing keys: ''' -$ step ca provisioner add max@smallstep.com ./max-laptop.jwk ./max-phone.pem ./max-work.pem \ ---ca-config ca.json +step ca provisioner add jane@doe.com --type JWK --public-key jwk.pub --private-key jwk.priv ''' -Add a single OIDC provisioner: +Create an OIDC provisioner: ''' -$ step ca provisioner add Google --type oidc --ca-config ca.json \ - --client-id 1087160488420-8qt7bavg3qesdhs6it824mhnfgcfe8il.apps.googleusercontent.com \ - --configuration-endpoint https://accounts.google.com/.well-known/openid-configuration +step ca provisioner add Google --type OIDC --ssh \ + --client-id 1087160488420-8qt7bavg3qesdhs6it824mhnfgcfe8il.apps.googleusercontent.com \ + --client-secret udTrOT3gzrO7W9fDPgZQLfYJ \ + --configuration-endpoint https://accounts.google.com/.well-known/openid-configuration ''' -Add an OIDC provisioner with two administrators: +Create an X5C provisioner: ''' -$ step ca provisioner add Google --type oidc --ca-config ca.json \ - --client-id 1087160488420-8qt7bavg3qesdhs6it824mhnfgcfe8il.apps.googleusercontent.com \ - --client-secret udTrOT3gzrO7W9fDPgZQLfYJ \ - --configuration-endpoint https://accounts.google.com/.well-known/openid-configuration \ - --admin mariano@smallstep.com --admin max@smallstep.com \ - --domain smallstep.com +step ca provisioner add x5c --type X5C --x5c-roots x5c_ca.crt ''' -Add an AWS provisioner on one account with a one hour of instance age: +Create an ACME provisioner: ''' -$ step ca provisioner add Amazon --type AWS --ca-config ca.json \ - --aws-account 123456789 --instance-age 1h +step ca provisioner add acme --type ACME ''' -Add an GCP provisioner with two service accounts and two project ids: +Create an ACME provisioner, forcing a CN and requiring EAB: ''' -$ step ca provisioner add Google --type GCP --ca-config ca.json \ - --gcp-service-account 1234567890-compute@developer.gserviceaccount.com \ - --gcp-service-account 9876543210-compute@developer.gserviceaccount.com \ - --gcp-project identity --gcp-project accounting +step ca provisioner add acme --type ACME --force-cn --require-eab ''' -Add an Azure provisioner with two service groups: +Create an ACME provisioner for device attestation: ''' -$ step ca provisioner add Azure --type Azure --ca-config ca.json \ - --azure-tenant bc9043e2-b645-4c1c-a87a-78f8644bfe57 \ - --azure-resource-group identity --azure-resource-group accounting +step ca provisioner add attestation --type ACME --challenge device-attest-01 ''' -Add an GCP provisioner that will only accept the SANs provided in the identity token: +Create an K8SSA provisioner: ''' -$ step ca provisioner add Google --type GCP --ca-config ca.json \ - --disable-custom-sans --gcp-project internal +step ca provisioner add kube --type K8SSA --ssh --public-key key.pub ''' -Add an AWS provisioner that will only accept the SANs provided in the identity -document and will allow multiple certificates from the same instance: +Create an SSHPOP provisioner for renewing SSH host certificates:") ''' -$ step ca provisioner add Amazon --type AWS --ca-config ca.json \ - --aws-account 123456789 --disable-custom-sans --disable-trust-on-first-use +step ca provisioner add sshpop --type SSHPOP ''' -Add an AWS provisioner that will use a custom certificate to validate the instance -identity documents: +Create a SCEP provisioner with 'secret' challenge and AES-256-CBC encryption: ''' -$ step ca provisioner add Amazon --type AWS --ca-config ca.json \ - --aws-account 123456789 --iid-roots $(step path)/certs/aws.crt +step ca provisioner add my_scep_provisioner --type SCEP --challenge secret --encryption-algorithm-identifier 2 ''' -Add an ACME provisioner. +Create an Azure provisioner with two resource groups, one subscription ID and one object ID: ''' -$ step ca provisioner add acme-smallstep --type ACME +$ step ca provisioner add Azure --type Azure \ + --azure-tenant bc9043e2-b645-4c1c-a87a-78f8644bfe57 \ + --azure-resource-group identity --azure-resource-group accounting \ + --azure-subscription-id dc760a01-2886-4a84-9abc-f3508e0f87d9 \ + --azure-object-id f50926c7-abbf-4c28-87dc-9adc7eaf3ba7 ''' -Add an X5C provisioner. +Create a GCP provisioner that will only accept the SANs provided in the identity token: ''' -$ step ca provisioner add x5c-smallstep --type X5C --x5c-root x5cRoot.crt +$ step ca provisioner add Google --type GCP \ + --disable-custom-sans --gcp-project internal ''' -Add a K8s Service Account provisioner. +Create a GCP provisioner that can be used across all projects within an organization: ''' -$ step ca provisioner add my-kube-provisioner --type K8sSA --pem-keys keys.pub +$ step ca provisioner add Google --type GCP --gcp-organization 123456789 ''' -Add an SSH-POP provisioner. +Create an AWS provisioner that will only accept the SANs provided in the identity +document and will allow multiple certificates from the same instance: ''' -$ step ca provisioner add sshpop-smallstep --type SSHPOP +$ step ca provisioner add Amazon --type AWS \ + --aws-account 123456789 --disable-custom-sans --disable-trust-on-first-use +''' + +Create an AWS provisioner that will use a custom certificate to validate the instance +identity documents: +''' +$ step ca provisioner add Amazon --type AWS \ + --aws-account 123456789 '''`, } } func addAction(ctx *cli.Context) (err error) { - if ctx.NArg() == 0 { - return errs.TooFewArguments(ctx) + if err := errs.NumberOfArguments(ctx, 1); err != nil { + return err } + x509TemplateFile := ctx.String("x509-template") + x509TemplateDataFile := ctx.String("x509-template-data") + sshTemplateFile := ctx.String("ssh-template") + sshTemplateDataFile := ctx.String("ssh-template-data") + args := ctx.Args() - name := args[0] - caCfg := ctx.String("ca-config") - if caCfg == "" { - return errs.RequiredFlag(ctx, "ca-config") + typ, ok := linkedca.Provisioner_Type_value[strings.ToUpper(ctx.String("type"))] + if !ok { + return errs.InvalidFlagValue(ctx, "type", ctx.String("type"), "JWK, ACME, OIDC, SSHPOP, K8SSA, NEBULA, SCEP, AWS, GCP, AZURE") } - c, err := config.LoadConfiguration(caCfg) - if err != nil { - return errors.Wrapf(err, "error loading configuration") + p := &linkedca.Provisioner{ + Name: args.Get(0), + Type: linkedca.Provisioner_Type(typ), } - typ, err := parseProvisionerType(ctx) - if err != nil { + // Validate challenge flag on scep and acme + if err := validateChallengeFlag(ctx, p.Type); err != nil { return err } + // Validate attestation format flag on acme + if err := validateAttestationFormatFlag(ctx, p.Type); err != nil { + return err + } + + // Read x509 template if passed + p.X509Template = &linkedca.Template{} + if x509TemplateFile != "" { + b, err := utils.ReadFile(x509TemplateFile) + if err != nil { + return err + } + p.X509Template.Template = b + } + if x509TemplateDataFile != "" { + b, err := utils.ReadFile(x509TemplateDataFile) + if err != nil { + return err + } + p.X509Template.Data = b + } + // Read ssh template if passed + p.SshTemplate = &linkedca.Template{} + if sshTemplateFile != "" { + b, err := utils.ReadFile(sshTemplateFile) + if err != nil { + return err + } + p.SshTemplate.Template = b + } + if sshTemplateDataFile != "" { + b, err := utils.ReadFile(sshTemplateDataFile) + if err != nil { + return err + } + p.SshTemplate.Data = b + } - provMap := make(map[string]bool) - for _, p := range c.AuthorityConfig.Provisioners { - provMap[p.GetID()] = true + p.Claims = &linkedca.Claims{ + X509: &linkedca.X509Claims{ + Durations: &linkedca.Durations{}, + Enabled: true, + // TODO: in the future we may add the ability to disable x509. + // Enabled: !(ctx.IsSet("x509") && !ctx.Bool("x509")), + }, + Ssh: &linkedca.SSHClaims{ + UserDurations: &linkedca.Durations{}, + HostDurations: &linkedca.Durations{}, + Enabled: !(ctx.IsSet("ssh") && !ctx.Bool("ssh")), //nolint:staticcheck // TODO(hs): fix this + }, + DisableRenewal: ctx.Bool("disable-renewal"), + AllowRenewalAfterExpiry: ctx.Bool("allow-renewal-after-expiry"), + DisableSmallstepExtensions: ctx.Bool("disable-smallstep-extensions"), } - var list provisioner.List - switch typ { - case provisioner.TypeJWK: - list, err = addJWKProvisioner(ctx, name, provMap) - case provisioner.TypeOIDC: - list, err = addOIDCProvisioner(ctx, name, provMap) - case provisioner.TypeAWS: - list, err = addAWSProvisioner(ctx, name, provMap) - case provisioner.TypeAzure: - list, err = addAzureProvisioner(ctx, name, provMap) - case provisioner.TypeGCP: - list, err = addGCPProvisioner(ctx, name, provMap) - case provisioner.TypeACME: - list, err = addACMEProvisioner(ctx, name, provMap) - case provisioner.TypeX5C: - list, err = addX5CProvisioner(ctx, name, provMap) - case provisioner.TypeK8sSA: - list, err = addK8sSAProvisioner(ctx, name, provMap) - case provisioner.TypeSSHPOP: - list, err = addSSHPOPProvisioner(ctx, name, provMap) - default: - return errors.Errorf("unknown type %s: this should not happen", typ) + if ctx.IsSet("x509-min-dur") { + p.Claims.X509.Durations.Min = ctx.String("x509-min-dur") + } + if ctx.IsSet("x509-max-dur") { + p.Claims.X509.Durations.Max = ctx.String("x509-max-dur") + } + if ctx.IsSet("x509-default-dur") { + p.Claims.X509.Durations.Default = ctx.String("x509-default-dur") + } + if ctx.IsSet("ssh-user-min-dur") { + p.Claims.Ssh.UserDurations.Min = ctx.String("ssh-user-min-dur") + } + if ctx.IsSet("ssh-user-max-dur") { + p.Claims.Ssh.UserDurations.Max = ctx.String("ssh-user-max-dur") + } + if ctx.IsSet("ssh-user-default-dur") { + p.Claims.Ssh.UserDurations.Default = ctx.String("ssh-user-default-dur") + } + if ctx.IsSet("ssh-host-min-dur") { + p.Claims.Ssh.HostDurations.Min = ctx.String("ssh-host-min-dur") + } + if ctx.IsSet("ssh-host-max-dur") { + p.Claims.Ssh.HostDurations.Max = ctx.String("ssh-host-max-dur") + } + if ctx.IsSet("ssh-host-default-dur") { + p.Claims.Ssh.HostDurations.Default = ctx.String("ssh-host-default-dur") } + switch p.Type { + case linkedca.Provisioner_ACME: + p.Details, err = createACMEDetails(ctx) + case linkedca.Provisioner_SSHPOP: + p.Details, err = createSSHPOPDetails(ctx) + case linkedca.Provisioner_X5C: + p.Details, err = createX5CDetails(ctx) + case linkedca.Provisioner_K8SSA: + p.Details, err = createK8SSADetails(ctx) + case linkedca.Provisioner_OIDC: + p.Details, err = createOIDCDetails(ctx) + case linkedca.Provisioner_AWS: + p.Details, err = createAWSDetails(ctx) + case linkedca.Provisioner_AZURE: + p.Details, err = createAzureDetails(ctx) + case linkedca.Provisioner_GCP: + p.Details, err = createGCPDetails(ctx) + case linkedca.Provisioner_SCEP: + p.Details, err = createSCEPDetails(ctx) + case linkedca.Provisioner_NEBULA: + p.Details, err = createNebulaDetails(ctx) + default: + p.Details, err = createJWKDetails(ctx) + } if err != nil { return err } - c.AuthorityConfig.Provisioners = append(c.AuthorityConfig.Provisioners, list...) - if err := c.Save(caCfg); err != nil { + client, err := newCRUDClient(ctx, ctx.String("ca-config")) + if err != nil { return err } - ui.Println("Success! Your `step-ca` config has been updated. To pick up the new configuration SIGHUP (kill -1 ) or restart the step-ca process.") + if _, err = client.CreateProvisioner(p); err != nil { + return err + } return nil } -func addJWKProvisioner(ctx *cli.Context, name string, provMap map[string]bool) (list provisioner.List, err error) { - var password string - if passwordFile := ctx.String("password-file"); len(passwordFile)> 0 { +func createJWKDetails(ctx *cli.Context) (*linkedca.ProvisionerDetails, error) { + var ( + err error + password string + ) + + if passwordFile := ctx.String("password-file"); passwordFile != "" { password, err = utils.ReadStringPasswordFromFile(passwordFile) if err != nil { return nil, err } } + var ( + jwk *jose.JSONWebKey + jwe *jose.JSONWebEncryption + ) if ctx.Bool("create") { - if ctx.NArg()> 1 { - return nil, errs.IncompatibleFlag(ctx, "create", " positional arg") + if ctx.IsSet("public-key") { + return nil, errs.IncompatibleFlag(ctx, "create", "public-key") + } + if ctx.IsSet("private-key") { + return nil, errs.IncompatibleFlag(ctx, "create", "private-key") } pass, err := ui.PromptPasswordGenerate("Please enter a password to encrypt the provisioner private key? [leave empty and we'll generate one]", ui.WithValue(password)) if err != nil { return nil, err } - jwk, jwe, err := jose.GenerateDefaultKeyPair(pass) + jwk, jwe, err = jose.GenerateDefaultKeyPair(pass) if err != nil { return nil, err } - encryptedKey, err := jwe.CompactSerialize() - if err != nil { - return nil, errors.Wrap(err, "error serializing private key") - } - - // Create provisioner - p := &provisioner.JWK{ - Type: provisioner.TypeJWK.String(), - Name: name, - Key: jwk, - EncryptedKey: encryptedKey, - Claims: getClaims(ctx), - } - // Check for duplicates - if _, ok := provMap[p.GetID()]; !ok { - provMap[p.GetID()] = true - } else { - return nil, errors.Errorf("duplicated provisioner: CA config already contains a provisioner with name=%s and kid=%s", name, jwk.KeyID) + } else { + if !ctx.IsSet("public-key") { + return nil, errs.RequiredWithFlagValue(ctx, "create", "false", "public-key") } - list = append(list, p) - return list, nil - } - // Add multiple provisioners using JWK files. - if ctx.NArg() < 2 { - return nil, errs.TooFewArguments(ctx) - } - - jwkFiles := ctx.Args()[1:] - for _, filename := range jwkFiles { - jwk, err := jose.ParseKey(filename) + jwkFile := ctx.String("public-key") + jwk, err = jose.ReadKey(jwkFile) if err != nil { - return nil, errs.FileError(err, filename) + return nil, errs.FileError(err, jwkFile) } + // Only use asymmetric cryptography if _, ok := jwk.Key.([]byte); ok { return nil, errors.New("invalid JWK: a symmetric key cannot be used as a provisioner") @@ -439,188 +521,117 @@ func addJWKProvisioner(ctx *cli.Context, name string, provMap map[string]bool) ( return nil, err } } - key := jwk.Public() - // Initialize provisioner and check for duplicates - p := &provisioner.JWK{ - Type: provisioner.TypeJWK.String(), - Name: name, - Key: &key, - Claims: getClaims(ctx), - } - if _, ok := provMap[p.GetID()]; !ok { - provMap[p.GetID()] = true - } else { - return nil, errors.Errorf("duplicated provisioner: CA config already contains a provisioner with name=%s and kid=%s", name, jwk.KeyID) - } - - // Encrypt JWK - if !jwk.IsPublic() { - jwe, err := jose.EncryptJWK(jwk) + if ctx.IsSet("private-key") { + jwkFile = ctx.String("private-key") + b, err := os.ReadFile(jwkFile) if err != nil { - return nil, err + return nil, errs.FileError(err, jwkFile) } - encryptedKey, err := jwe.CompactSerialize() + + // Attempt to parse private key as Encrypted JSON. + // If this operation fails then either, + // 1. the key is not encrypted + // 2. the key has an invalid format + // + // Attempt to parse as decrypted private key. + jwe, err = jose.ParseEncrypted(string(b)) if err != nil { - return nil, errors.Wrap(err, "error serializing private key") + privjwk, err := jose.ParseKey(b, jose.WithFilename(jwkFile)) + if err != nil { + return nil, err + } + + if privjwk.IsPublic() { + return nil, errors.New("invalid jwk: private-key is a public key") + } + + // Encrypt JWK + var passbytes []byte + if ctx.IsSet("password-file") { + passbytes, err = os.ReadFile(ctx.String("password-file")) + if err != nil { + return nil, errs.FileError(err, ctx.String("password-file")) + } + } else { + passbytes, err = ui.PromptPasswordGenerate("Please enter a password to encrypt the provisioner private key? [leave empty and we'll generate one]", + ui.WithValue(password)) + if err != nil { + return nil, err + } + } + jwe, err = jose.EncryptJWK(privjwk, passbytes) + if err != nil { + return nil, err + } } - p.EncryptedKey = encryptedKey } - - list = append(list, p) } - return list, nil -} -func addOIDCProvisioner(ctx *cli.Context, name string, provMap map[string]bool) (list provisioner.List, err error) { - clientID := ctx.String("client-id") - if clientID == "" { - return nil, errs.RequiredWithFlagValue(ctx, "type", ctx.String("type"), "client-id") - } - - confURL := ctx.String("configuration-endpoint") - if confURL == "" { - return nil, errs.RequiredWithFlagValue(ctx, "type", ctx.String("type"), "configuration-endpoint") - } - u, err := url.Parse(confURL) - if err != nil || (u.Scheme != "https" && u.Scheme != "http") { - return nil, errs.InvalidFlagValue(ctx, "configuration-endpoint", confURL, "") - } - - // Create provisioner - p := &provisioner.OIDC{ - Type: provisioner.TypeOIDC.String(), - Name: name, - ClientID: clientID, - ClientSecret: ctx.String("client-secret"), - ConfigurationEndpoint: confURL, - Admins: ctx.StringSlice("admin"), - Domains: ctx.StringSlice("domain"), - Claims: getClaims(ctx), - ListenAddress: ctx.String("listen-address"), - } - // Check for duplicates - if _, ok := provMap[p.GetID()]; !ok { - provMap[p.GetID()] = true - } else { - return nil, errors.Errorf("duplicated provisioner: CA config already contains a provisioner with name=%s and client-id=%s", p.GetName(), p.GetID()) - } - list = append(list, p) - return -} - -func addAWSProvisioner(ctx *cli.Context, name string, provMap map[string]bool) (list provisioner.List, err error) { - d, err := parseIntaceAge(ctx) + jwkPubBytes, err := jwk.MarshalJSON() if err != nil { - return nil, err - } - - p := &provisioner.AWS{ - Type: provisioner.TypeAWS.String(), - Name: name, - Accounts: ctx.StringSlice("aws-account"), - DisableCustomSANs: ctx.Bool("disable-custom-sans"), - DisableTrustOnFirstUse: ctx.Bool("disable-trust-on-first-use"), - InstanceAge: d, - IIDRoots: ctx.String("iid-roots"), - Claims: getClaims(ctx), + return nil, errors.Wrap(err, "error marshaling JWK") } - - // Check for duplicates - if _, ok := provMap[p.GetID()]; !ok { - provMap[p.GetID()] = true - } else { - return nil, errors.Errorf("duplicated provisioner: CA config already contains a provisioner with type=AWS and name=%s", p.GetName()) + jwkProv := &linkedca.JWKProvisioner{ + PublicKey: jwkPubBytes, } - list = append(list, p) - return -} - -func addAzureProvisioner(ctx *cli.Context, name string, provMap map[string]bool) (list provisioner.List, err error) { - tenantID := ctx.String("azure-tenant") - if tenantID == "" { - return nil, errs.RequiredWithFlagValue(ctx, "type", ctx.String("type"), "azure-tenant") - } - - p := &provisioner.Azure{ - Type: provisioner.TypeAzure.String(), - Name: name, - TenantID: tenantID, - ResourceGroups: ctx.StringSlice("azure-resource-group"), - DisableCustomSANs: ctx.Bool("disable-custom-sans"), - DisableTrustOnFirstUse: ctx.Bool("disable-trust-on-first-use"), - Claims: getClaims(ctx), - } - - // Check for duplicates - if _, ok := provMap[p.GetID()]; !ok { - provMap[p.GetID()] = true - } else { - return nil, errors.Errorf("duplicated provisioner: CA config already contains a provisioner with type=Azure and name=%s", p.GetName()) + if jwe != nil { + jwePrivStr, err := jwe.CompactSerialize() + if err != nil { + return nil, errors.Wrap(err, "error serializing JWE") + } + jwkProv.EncryptedPrivateKey = []byte(jwePrivStr) } - list = append(list, p) - return + return &linkedca.ProvisionerDetails{ + Data: &linkedca.ProvisionerDetails_JWK{ + JWK: jwkProv, + }, + }, nil } -func addGCPProvisioner(ctx *cli.Context, name string, provMap map[string]bool) (list provisioner.List, err error) { - d, err := parseIntaceAge(ctx) +func createACMEDetails(ctx *cli.Context) (*linkedca.ProvisionerDetails, error) { + attestationRoots, err := parseCACertificates(ctx.StringSlice("attestation-roots")) if err != nil { return nil, err } - - p := &provisioner.GCP{ - Type: provisioner.TypeGCP.String(), - Name: name, - ServiceAccounts: ctx.StringSlice("gcp-service-account"), - ProjectIDs: ctx.StringSlice("gcp-project"), - DisableCustomSANs: ctx.Bool("disable-custom-sans"), - DisableTrustOnFirstUse: ctx.Bool("disable-trust-on-first-use"), - InstanceAge: d, - Claims: getClaims(ctx), - } - - // Check for duplicates - if _, ok := provMap[p.GetID()]; !ok { - provMap[p.GetID()] = true - } else { - return nil, errors.Errorf("duplicated provisioner: CA config already contains a provisioner with type=GCP and name=%s", p.GetName()) - } - - list = append(list, p) - return + return &linkedca.ProvisionerDetails{ + Data: &linkedca.ProvisionerDetails_ACME{ + ACME: &linkedca.ACMEProvisioner{ + ForceCn: ctx.Bool("force-cn"), + RequireEab: ctx.Bool("require-eab"), + Challenges: sliceutil.RemoveDuplicates( + acmeChallengeToLinkedca(ctx.StringSlice("challenge")), + ), + AttestationFormats: sliceutil.RemoveDuplicates( + acmeAttestationFormatToLinkedca(ctx.StringSlice("attestation-format")), + ), + AttestationRoots: attestationRoots, + }, + }, + }, nil } -func addACMEProvisioner(ctx *cli.Context, name string, provMap map[string]bool) (list provisioner.List, err error) { - p := &provisioner.ACME{ - Type: provisioner.TypeACME.String(), - Name: name, - Claims: getClaims(ctx), - } - - // Check for duplicates - if _, ok := provMap[p.GetID()]; !ok { - provMap[p.GetID()] = true - } else { - return nil, errors.Errorf("duplicated provisioner: CA config already contains a provisioner with ID==%s", p.GetID()) - } - - list = append(list, p) - return +func createSSHPOPDetails(*cli.Context) (*linkedca.ProvisionerDetails, error) { + return &linkedca.ProvisionerDetails{ + Data: &linkedca.ProvisionerDetails_SSHPOP{ + SSHPOP: &linkedca.SSHPOPProvisioner{}, + }, + }, nil } -func addX5CProvisioner(ctx *cli.Context, name string, provMap map[string]bool) (list provisioner.List, err error) { - x5cRootFile := ctx.String("x5c-root") +func createX5CDetails(ctx *cli.Context) (*linkedca.ProvisionerDetails, error) { + x5cRootFile := ctx.String("x5c-roots") if x5cRootFile == "" { - return nil, errs.RequiredWithFlagValue(ctx, "type", "x5c", "x5c-root") + return nil, errs.RequiredWithFlagValue(ctx, "type", "x5c", "x5c-roots") } roots, err := pemutil.ReadCertificateBundle(x5cRootFile) if err != nil { return nil, errors.Wrapf(err, "error loading X5C Root certificates from %s", x5cRootFile) } - var rootBytes []byte + var rootBytes [][]byte for _, r := range roots { if r.KeyUsage&x509.KeyUsageCertSign == 0 { return nil, errors.Errorf("error: certificate with common name '%s' cannot be "+ @@ -631,35 +642,41 @@ func addX5CProvisioner(ctx *cli.Context, name string, provMap map[string]bool) ( rootBytes = append(rootBytes, pem.EncodeToMemory(&pem.Block{ Type: "CERTIFICATE", Bytes: r.Raw, - })...) + })) } - p := &provisioner.X5C{ - Type: provisioner.TypeX5C.String(), - Name: name, - Claims: getClaims(ctx), - Roots: rootBytes, + return &linkedca.ProvisionerDetails{ + Data: &linkedca.ProvisionerDetails_X5C{ + X5C: &linkedca.X5CProvisioner{ + Roots: rootBytes, + }, + }, + }, nil +} + +func createNebulaDetails(ctx *cli.Context) (*linkedca.ProvisionerDetails, error) { + rootFile := ctx.String("nebula-root") + if rootFile == "" { + return nil, errs.RequiredWithFlagValue(ctx, "type", "nebula", "nebula-root") } - // Check for duplicates - if _, ok := provMap[p.GetID()]; !ok { - provMap[p.GetID()] = true - } else { - return nil, errors.Errorf("duplicated provisioner: CA config already contains a provisioner with ID=%s", p.GetID()) + rootBytes, err := readNebulaRoots(rootFile) + if err != nil { + return nil, err } - list = append(list, p) - return + return &linkedca.ProvisionerDetails{ + Data: &linkedca.ProvisionerDetails_Nebula{ + Nebula: &linkedca.NebulaProvisioner{ + Roots: rootBytes, + }, + }, + }, nil } -// addK8sSAProvisioner returns a provisioner list containing a kubernetes -// service account provisioner. -// NOTE: step-ca currently only supports one k8sSA provisioner (because we do -// not have a good way of distinguishing between tokens), therefore w/e `name` -// is entered by the user will be overwritten by a default value. -func addK8sSAProvisioner(ctx *cli.Context, name string, provMap map[string]bool) (list provisioner.List, err error) { - pemKeysF := ctx.String("pem-keys") +func createK8SSADetails(ctx *cli.Context) (*linkedca.ProvisionerDetails, error) { + pemKeysF := ctx.String("public-key") if pemKeysF == "" { - return nil, errs.RequiredWithFlagValue(ctx, "type", "k8sSA", "pem-keys") + return nil, errs.RequiredWithFlagValue(ctx, "type", "k8sSA", "public-key") } pemKeysB, err := os.ReadFile(pemKeysF) @@ -689,96 +706,346 @@ func addK8sSAProvisioner(ctx *cli.Context, name string, provMap map[string]bool) pemKeys = append(pemKeys, key) } - var pubKeyBytes []byte + var pubKeyBytes [][]byte for _, k := range pemKeys { blk, err := pemutil.Serialize(k) if err != nil { return nil, errors.Wrap(err, "error serializing pem key") } - pubKeyBytes = append(pubKeyBytes, pem.EncodeToMemory(blk)...) + pubKeyBytes = append(pubKeyBytes, pem.EncodeToMemory(blk)) } + return &linkedca.ProvisionerDetails{ + Data: &linkedca.ProvisionerDetails_K8SSA{ + K8SSA: &linkedca.K8SSAProvisioner{ + PublicKeys: pubKeyBytes, + }, + }, + }, nil +} - p := &provisioner.K8sSA{ - Type: provisioner.TypeK8sSA.String(), - Name: name, - Claims: getClaims(ctx), - PubKeys: pubKeyBytes, +func createOIDCDetails(ctx *cli.Context) (*linkedca.ProvisionerDetails, error) { + clientID := ctx.String("client-id") + if clientID == "" { + return nil, errs.RequiredWithFlagValue(ctx, "type", ctx.String("type"), "client-id") } - // Check for duplicates - if _, ok := provMap[p.GetID()]; !ok { - provMap[p.GetID()] = true - } else { - return nil, errors.Errorf("duplicated provisioner: CA config already contains a provisioner with ID=%s", p.GetID()) + confURL := ctx.String("configuration-endpoint") + if confURL == "" { + return nil, errs.RequiredWithFlagValue(ctx, "type", ctx.String("type"), "configuration-endpoint") + } + u, err := url.Parse(confURL) + if err != nil || (u.Scheme != "https" && u.Scheme != "http") { + return nil, errs.InvalidFlagValue(ctx, "configuration-endpoint", confURL, "") } - list = append(list, p) - return + return &linkedca.ProvisionerDetails{ + Data: &linkedca.ProvisionerDetails_OIDC{ + OIDC: &linkedca.OIDCProvisioner{ + ClientId: clientID, + ClientSecret: ctx.String("client-secret"), + ConfigurationEndpoint: confURL, + Admins: ctx.StringSlice("admin"), + Domains: ctx.StringSlice("domain"), + Groups: ctx.StringSlice("group"), + ListenAddress: ctx.String("listen-address"), + TenantId: ctx.String("tenant-id"), + }, + }, + }, nil } -// addSSHPOPProvisioner returns a provisioner list containing a SSHPOP provisioner. -func addSSHPOPProvisioner(ctx *cli.Context, name string, provMap map[string]bool) (list provisioner.List, err error) { - ctx.Set("ssh", "true") - p := &provisioner.SSHPOP{ - Type: provisioner.TypeSSHPOP.String(), - Name: name, - Claims: getClaims(ctx), +func createAWSDetails(ctx *cli.Context) (*linkedca.ProvisionerDetails, error) { + if ctx.IsSet("disable-ssh-ca-user") { + return nil, errors.New("flag disable-ssh-ca-user is not supported for AWS IID provisioners") + } + if ctx.IsSet("disable-ssh-ca-host") { + return nil, errors.New("flag disable-ssh-ca-host is not supported for AWS IID provisioners") } - // Check for duplicates - if _, ok := provMap[p.GetID()]; !ok { - provMap[p.GetID()] = true - } else { - return nil, errors.Errorf("duplicated provisioner: CA config already contains a provisioner with ID=%s", p.GetID()) + d, err := parseInstanceAge(ctx) + if err != nil { + return nil, err } - list = append(list, p) - return + return &linkedca.ProvisionerDetails{ + Data: &linkedca.ProvisionerDetails_AWS{ + AWS: &linkedca.AWSProvisioner{ + Accounts: ctx.StringSlice("aws-account"), + DisableCustomSans: ctx.Bool("disable-custom-sans"), + DisableTrustOnFirstUse: ctx.Bool("disable-trust-on-first-use"), + InstanceAge: d, + // TODO IID Roots + // IIDRoots: ctx.String("iid-roots"), + }, + }, + }, nil } -func getClaims(ctx *cli.Context) *provisioner.Claims { - if ctx.Bool("ssh") { - enable := true - return &provisioner.Claims{ - EnableSSHCA: &enable, +func createAzureDetails(ctx *cli.Context) (*linkedca.ProvisionerDetails, error) { + if ctx.IsSet("disable-ssh-ca-user") { + return nil, errors.New("flag disable-ssh-ca-user is not supported for Azure IID provisioners") + } + if ctx.IsSet("disable-ssh-ca-host") { + return nil, errors.New("flag disable-ssh-ca-host is not supported for Azure IID provisioners") + } + + tenantID := ctx.String("azure-tenant") + if tenantID == "" { + return nil, errs.RequiredWithFlagValue(ctx, "type", ctx.String("type"), "azure-tenant") + } + + return &linkedca.ProvisionerDetails{ + Data: &linkedca.ProvisionerDetails_Azure{ + Azure: &linkedca.AzureProvisioner{ + TenantId: tenantID, + ResourceGroups: ctx.StringSlice("azure-resource-group"), + Audience: ctx.String("azure-audience"), + SubscriptionIds: ctx.StringSlice("azure-subscription-id"), + ObjectIds: ctx.StringSlice("azure-object-id"), + DisableCustomSans: ctx.Bool("disable-custom-sans"), + DisableTrustOnFirstUse: ctx.Bool("disable-trust-on-first-use"), + }, + }, + }, nil +} + +func createGCPDetails(ctx *cli.Context) (*linkedca.ProvisionerDetails, error) { + if ctx.String("gcp-organization") != "" && len(ctx.StringSlice("gcp-project"))> 0 { + return nil, errs.IncompatibleFlagWithFlag(ctx, "gcp-organization", "gcp-project") + } + + d, err := parseInstanceAge(ctx) + if err != nil { + return nil, err + } + + var ( + disableSSHCAUser *bool + disableSSHCAHost *bool + ) + + if ctx.IsSet("disable-ssh-ca-user") { + boolVal := ctx.Bool("disable-ssh-ca-user") + disableSSHCAUser = &boolVal + } + if ctx.IsSet("disable-ssh-ca-host") { + boolVal := ctx.Bool("disable-ssh-ca-host") + disableSSHCAHost = &boolVal + } + + return &linkedca.ProvisionerDetails{ + Data: &linkedca.ProvisionerDetails_GCP{ + GCP: &linkedca.GCPProvisioner{ + ServiceAccounts: ctx.StringSlice("gcp-service-account"), + ProjectIds: ctx.StringSlice("gcp-project"), + OrganizationId: ctx.String("gcp-organization"), + DisableCustomSans: ctx.Bool("disable-custom-sans"), + DisableTrustOnFirstUse: ctx.Bool("disable-trust-on-first-use"), + DisableSshCaUser: disableSSHCAUser, + DisableSshCaHost: disableSSHCAHost, + InstanceAge: d, + }, + }, + }, nil +} + +func createSCEPDetails(ctx *cli.Context) (*linkedca.ProvisionerDetails, error) { + var challenge string + // We have already validated that at most 1 is provided. + if v := ctx.StringSlice("challenge"); len(v)> 0 { + challenge = v[0] + } + s := &linkedca.SCEPProvisioner{ + ForceCn: ctx.Bool("force-cn"), + Challenge: challenge, + Capabilities: ctx.StringSlice("capabilities"), + MinimumPublicKeyLength: cast.Int32(ctx.Int("min-public-key-length")), + IncludeRoot: ctx.Bool("include-root"), + ExcludeIntermediate: ctx.Bool("exclude-intermediate"), + EncryptionAlgorithmIdentifier: cast.Int32(ctx.Int("encryption-algorithm-identifier")), + } + decrypter := &linkedca.SCEPDecrypter{} + if decrypterCertificateFile := ctx.String("scep-decrypter-certificate-file"); decrypterCertificateFile != "" { + data, err := parseSCEPDecrypterCertificate(decrypterCertificateFile) + if err != nil { + return nil, fmt.Errorf("failed parsing certificate from %q: %w", decrypterCertificateFile, err) + } + decrypter.Certificate = data + s.Decrypter = decrypter + } + if decrypterKeyURI := ctx.String("scep-decrypter-key-uri"); decrypterKeyURI != "" { + decrypter.KeyUri = decrypterKeyURI + s.Decrypter = decrypter + } + if decrypterKeyFile := ctx.String("scep-decrypter-key-file"); decrypterKeyFile != "" { + data, err := readSCEPDecrypterKey(decrypterKeyFile) + if err != nil { + return nil, fmt.Errorf("failed reading decrypter key from %q: %w", decrypterKeyFile, err) + } + decrypter.Key = data + s.Decrypter = decrypter + } + if decrypterKeyPasswordFile := ctx.String("scep-decrypter-key-password-file"); decrypterKeyPasswordFile != "" { + decrypterKeyPassword, err := utils.ReadPasswordFromFile(decrypterKeyPasswordFile) + if err != nil { + return nil, fmt.Errorf("failed reading decrypter key password from %q: %w", decrypterKeyPasswordFile, err) + } + decrypter.KeyPassword = decrypterKeyPassword + s.Decrypter = decrypter + } + return &linkedca.ProvisionerDetails{ + Data: &linkedca.ProvisionerDetails_SCEP{ + SCEP: s, + }, + }, nil +} + +func validateChallengeFlag(ctx *cli.Context, typ linkedca.Provisioner_Type) error { + switch typ { + case linkedca.Provisioner_ACME: + for _, v := range ctx.StringSlice("challenge") { + switch strings.ToLower(v) { + case "http-01", "dns-01", "tls-alpn-01", "device-attest-01": + default: + return errs.InvalidFlagValue(ctx, "challenge", v, "http-01, dns-01, tls-alpn-01 and device-attest-01") + } + } + for _, v := range ctx.StringSlice("remove-challenge") { + switch strings.ToLower(v) { + case "http-01", "dns-01", "tls-alpn-01", "device-attest-01": + default: + return errs.InvalidFlagValue(ctx, "remove-challenge", v, "http-01, dns-01, tls-alpn-01 and device-attest-01") + } + } + + case linkedca.Provisioner_SCEP: + if len(ctx.StringSlice("challenge"))> 1 { + return errors.New("provisioner type 'SCEP' does not support multiple '--challenge' flags") + } + } + return nil +} + +func validateAttestationFormatFlag(ctx *cli.Context, typ linkedca.Provisioner_Type) error { + if typ == linkedca.Provisioner_ACME { + for _, v := range ctx.StringSlice("attestation-format") { + switch strings.ToLower(v) { + case "apple", "step", "tpm": + default: + return errs.InvalidFlagValue(ctx, "attestation-format", v, "apple, step, and tpm") + } + } + for _, v := range ctx.StringSlice("remove-attestation-format") { + switch strings.ToLower(v) { + case "apple", "step", "tpm": + default: + return errs.InvalidFlagValue(ctx, "remove-attestation-format", v, "apple, step, and tpm") + } } } return nil } -func parseIntaceAge(ctx *cli.Context) (provisioner.Duration, error) { - age := ctx.Duration("instance-age") - if age == 0 { - return provisioner.Duration{}, nil +// acmeChallengeToLinkedca returns the linkedca challenge types on the challenge +// flag. It won't fail or add unsupported flags, the function assumes the +// options have been previously validated. +func acmeChallengeToLinkedca(challenges []string) []linkedca.ACMEProvisioner_ChallengeType { + var ret []linkedca.ACMEProvisioner_ChallengeType + for _, v := range challenges { + switch strings.ToLower(v) { + case "http-01": + ret = append(ret, linkedca.ACMEProvisioner_HTTP_01) + case "dns-01": + ret = append(ret, linkedca.ACMEProvisioner_DNS_01) + case "tls-alpn-01": + ret = append(ret, linkedca.ACMEProvisioner_TLS_ALPN_01) + case "device-attest-01": + ret = append(ret, linkedca.ACMEProvisioner_DEVICE_ATTEST_01) + } } - if age < 0 { - return provisioner.Duration{}, errs.MinSizeFlag(ctx, "instance-age", "0s") + return ret +} + +// acmeAttestationFormatToLinkedca returns the linkedca attestation format types +// for the attestation-format flag. This function assumes the inputs have been +// previously validated. +func acmeAttestationFormatToLinkedca(formats []string) []linkedca.ACMEProvisioner_AttestationFormatType { + var ret []linkedca.ACMEProvisioner_AttestationFormatType + for _, v := range formats { + switch strings.ToLower(v) { + case "apple": + ret = append(ret, linkedca.ACMEProvisioner_APPLE) + case "step": + ret = append(ret, linkedca.ACMEProvisioner_STEP) + case "tpm": + ret = append(ret, linkedca.ACMEProvisioner_TPM) + } } - return provisioner.Duration{Duration: age}, nil + return ret } -func parseProvisionerType(ctx *cli.Context) (provisioner.Type, error) { - typ := ctx.String("type") - switch strings.ToLower(typ) { - case "", "jwk": - return provisioner.TypeJWK, nil - case "oidc": - return provisioner.TypeOIDC, nil - case "gcp": - return provisioner.TypeGCP, nil - case "aws": - return provisioner.TypeAWS, nil - case "azure": - return provisioner.TypeAzure, nil - case "acme": - return provisioner.TypeACME, nil - case "x5c": - return provisioner.TypeX5C, nil - case "sshpop": - return provisioner.TypeSSHPOP, nil - case "k8ssa": - return provisioner.TypeK8sSA, nil - default: - return 0, errs.InvalidFlagValue(ctx, "type", typ, "JWK, OIDC, AWS, Azure, GCP, ACME, X5C, SSHPOP, K8sSA") +func parseCACertificates(filenames []string) ([][]byte, error) { + var pemCerts [][]byte + for _, name := range filenames { + certs, err := pemutil.ReadCertificateBundle(name) + if err != nil { + return nil, errors.Wrapf(err, "error reading certificates from %s", name) + } + + for _, cert := range certs { + if !cert.IsCA || cert.KeyUsage&x509.KeyUsageCertSign == 0 { + return nil, errors.Errorf("certificate with common name %q is not a valid CA", cert.Subject.CommonName) + } + pemCerts = append(pemCerts, pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: cert.Raw, + })) + } } + return pemCerts, nil +} + +func parseSCEPDecrypterCertificate(filename string) ([]byte, error) { + certs, err := pemutil.ReadCertificateBundle(filename) + if err != nil { + return nil, fmt.Errorf("failed reading certificate from %q: %w", filename, err) + } + if len(certs) == 0 { + return nil, fmt.Errorf("no certificates found in %q", filename) + } + // TODO(hs): implement validation, such as key usage? + buf := bytes.Buffer{} + if err = pem.Encode(&buf, &pem.Block{ + Type: "CERTIFICATE", + Bytes: certs[0].Raw, // assumes the bundle is a certificate chain; using first cert as decrypter + }); err != nil { + return nil, fmt.Errorf("failed encoding certificate: %w", err) + } + return buf.Bytes(), nil +} + +func readSCEPDecrypterKey(filename string) ([]byte, error) { + b, err := os.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("failed reading %q: %w", filename, err) + } + + if err := validateSCEPDecrypterKey(b); err != nil { + return nil, fmt.Errorf("failed decoding %q: %w", filename, err) + } + + // TODO(hs): additional validation that this is an (encrypted) private key? + + return b, err +} + +func validateSCEPDecrypterKey(data []byte) error { + block, rest := pem.Decode(data) + switch { + case block == nil: + return errors.New("not a valid PEM encoded block") + case len(bytes.TrimSpace(rest))> 0: + return errors.New("contains more than one PEM encoded block") + } + return nil } diff --git a/command/ca/provisioner/caConfigClient.go b/command/ca/provisioner/caConfigClient.go new file mode 100644 index 00000000..eceb20a6 --- /dev/null +++ b/command/ca/provisioner/caConfigClient.go @@ -0,0 +1,221 @@ +package provisioner + +import ( + "context" + + "github.com/pkg/errors" + + "github.com/smallstep/certificates/authority" + "github.com/smallstep/certificates/authority/config" + "github.com/smallstep/certificates/authority/provisioner" + "github.com/smallstep/certificates/ca" + "github.com/smallstep/cli-utils/ui" + "github.com/smallstep/linkedca" +) + +// nodb implements the certificates/Adminclient interface with noops. +type nodb struct{} + +func newNoDB() *nodb { + return &nodb{} +} + +func (n *nodb) CreateProvisioner(context.Context, *linkedca.Provisioner) error { + return nil +} + +func (n *nodb) GetProvisioner(context.Context, string) (*linkedca.Provisioner, error) { + //nolint:nilnil // nodb is a noop interface. + return nil, nil +} + +func (n *nodb) GetProvisioners(context.Context) ([]*linkedca.Provisioner, error) { + return nil, nil +} + +func (n *nodb) UpdateProvisioner(context.Context, *linkedca.Provisioner) error { + return nil +} + +func (n *nodb) DeleteProvisioner(context.Context, string) error { + return nil +} + +func (n *nodb) CreateAdmin(context.Context, *linkedca.Admin) error { + return nil +} + +func (n *nodb) GetAdmin(context.Context, string) (*linkedca.Admin, error) { + //nolint:nilnil // nodb is a noop interface. + return nil, nil +} + +func (n *nodb) GetAdmins(context.Context) ([]*linkedca.Admin, error) { + return nil, nil +} + +func (n *nodb) UpdateAdmin(context.Context, *linkedca.Admin) error { + return nil +} + +func (n *nodb) DeleteAdmin(context.Context, string) error { + return nil +} + +func (n *nodb) CreateAuthorityPolicy(context.Context, *linkedca.Policy) error { + return nil +} + +func (n *nodb) GetAuthorityPolicy(context.Context) (*linkedca.Policy, error) { + //nolint:nilnil // nodb is a noop interface. + return nil, nil +} + +func (n *nodb) UpdateAuthorityPolicy(context.Context, *linkedca.Policy) error { + return nil +} + +func (n *nodb) DeleteAuthorityPolicy(context.Context) error { + return nil +} + +type caConfigClient struct { + configFile string + ctx context.Context + auth *authority.Authority +} + +func newCaConfigClient(ctx context.Context, cfg *config.Config, cfgFile string) (*caConfigClient, error) { + provClxn := provisioner.NewCollection(provisioner.Audiences{}) + for _, p := range cfg.AuthorityConfig.Provisioners { + if err := provClxn.Store(p); err != nil { + return nil, err + } + } + a, err := authority.New(cfg, authority.WithAdminDB(newNoDB()), + authority.WithSkipInit(), authority.WithProvisioners(provClxn)) //nolint:staticcheck // TODO: WithProvisioners has been deprecated, temporarily do not lint this line. + if err != nil { + return nil, errors.Wrapf(err, "error loading authority") + } + + return &caConfigClient{ + configFile: cfgFile, + ctx: ctx, + auth: a, + }, nil +} + +func (client *caConfigClient) CreateProvisioner(prov *linkedca.Provisioner) (*linkedca.Provisioner, error) { + if err := client.auth.StoreProvisioner(client.ctx, prov); err != nil { + return nil, errors.Wrapf(err, "error storing provisioner") + } + + if err := client.write(); err != nil { + return nil, err + } + + return prov, nil +} + +func (client *caConfigClient) GetProvisioner(opts ...ca.ProvisionerOption) (*linkedca.Provisioner, error) { + prov, err := client.loadProvisioner(opts...) + if err != nil { + return nil, err + } + linkedcaProv, err := authority.ProvisionerToLinkedca(prov) + if err != nil { + return nil, errors.Wrapf(err, "error converting provisioner interface to linkedca provisioner") + } + + return linkedcaProv, nil +} + +// NOTE: 'name' parameter has been deprecated and will be removed in a future +// minor release. +func (client *caConfigClient) UpdateProvisioner(name string, prov *linkedca.Provisioner) error { + _ = name + if err := client.auth.UpdateProvisioner(client.ctx, prov); err != nil { + return errors.Wrapf(err, "error updating provisioner") + } + + return client.write() +} + +func (client *caConfigClient) RemoveProvisioner(opts ...ca.ProvisionerOption) error { + prov, err := client.loadProvisioner(opts...) + if err != nil { + return err + } + if err := client.auth.RemoveProvisioner(client.ctx, prov.GetID()); err != nil { + return errors.Wrapf(err, "error removing provisioner") + } + + return client.write() +} + +func (client *caConfigClient) loadProvisioner(opts ...ca.ProvisionerOption) (provisioner.Interface, error) { + o := new(ca.ProvisionerOptions) + if err := o.Apply(opts); err != nil { + return nil, err + } + + var ( + err error + prov provisioner.Interface + ) + + switch { + case o.ID != "": + prov, err = client.auth.LoadProvisionerByID(o.ID) + case o.Name != "": + prov, err = client.auth.LoadProvisionerByName(o.Name) + default: + return nil, errors.New("provisioner options must define either ID or Name to remove") + } + + return prov, errors.Wrapf(err, "error loading provisioner") +} + +func (client *caConfigClient) GetProvisioners(opts ...ca.ProvisionerOption) (provisioner.List, error) { + o := new(ca.ProvisionerOptions) + if err := o.Apply(opts); err != nil { + return nil, err + } + + if o.Limit == 0 { + o.Limit = 100 + } + + var ( + cursor = o.Cursor + limit = o.Limit + provs = provisioner.List{} + ) + for { + page, nextCursor, err := client.auth.GetProvisioners(cursor, limit) + if err != nil { + return nil, err + } + provs = append(provs, page...) + if nextCursor == "" { + return provs, nil + } + cursor = nextCursor + } +} + +func (client *caConfigClient) write() error { + provs, err := client.GetProvisioners() + if err != nil { + return err + } + cfg := client.auth.GetConfig() + cfg.AuthorityConfig.Provisioners = provs + if err := cfg.Save(client.configFile); err != nil { + return err + } + + ui.Println("Success! Your `step-ca` config has been updated. To pick up the new configuration SIGHUP (kill -1 ) or restart the step-ca process.") + + return nil +} diff --git a/command/ca/provisioner/getEncryptedKey.go b/command/ca/provisioner/getEncryptedKey.go index 00c61df6..1c9a78b8 100644 --- a/command/ca/provisioner/getEncryptedKey.go +++ b/command/ca/provisioner/getEncryptedKey.go @@ -4,10 +4,12 @@ import ( "fmt" "github.com/pkg/errors" + "github.com/urfave/cli" + "github.com/smallstep/certificates/pki" + "github.com/smallstep/cli-utils/errs" + "github.com/smallstep/cli/flags" - "github.com/urfave/cli" - "go.step.sm/cli-utils/errs" ) func getEncryptedKeyCommand() cli.Command { diff --git a/command/ca/provisioner/list.go b/command/ca/provisioner/list.go index cebd0bc1..4e5efeec 100644 --- a/command/ca/provisioner/list.go +++ b/command/ca/provisioner/list.go @@ -5,10 +5,12 @@ import ( "fmt" "github.com/pkg/errors" + "github.com/urfave/cli" + "github.com/smallstep/certificates/pki" + "github.com/smallstep/cli-utils/errs" + "github.com/smallstep/cli/flags" - "github.com/urfave/cli" - "go.step.sm/cli-utils/errs" ) func listCommand() cli.Command { diff --git a/command/ca/provisioner/provisioner.go b/command/ca/provisioner/provisioner.go index 36c11c01..c0e9bcff 100644 --- a/command/ca/provisioner/provisioner.go +++ b/command/ca/provisioner/provisioner.go @@ -1,6 +1,26 @@ package provisioner -import "github.com/urfave/cli" +import ( + "context" + "fmt" + "net" + "time" + + "github.com/pkg/errors" + nebula "github.com/slackhq/nebula/cert" + "github.com/urfave/cli" + + "github.com/smallstep/certificates/authority/config" + "github.com/smallstep/certificates/authority/provisioner" + "github.com/smallstep/certificates/ca" + "github.com/smallstep/cli-utils/errs" + "github.com/smallstep/cli-utils/ui" + "github.com/smallstep/linkedca" + + "github.com/smallstep/cli/command/ca/provisioner/webhook" + "github.com/smallstep/cli/utils" + "github.com/smallstep/cli/utils/cautils" +) // Command returns the jwk subcommand. func Command() cli.Command { @@ -12,7 +32,9 @@ func Command() cli.Command { listCommand(), getEncryptedKeyCommand(), addCommand(), + updateCommand(), removeCommand(), + webhook.Command(), }, Description: `**step ca provisioner** command group provides facilities for managing the certificate authority provisioners. @@ -72,3 +94,586 @@ $ step ca provisioner remove max@smallstep.com --kid 1234 --ca-config ca.json '''`, } } + +type crudClient interface { + CreateProvisioner(prov *linkedca.Provisioner) (*linkedca.Provisioner, error) + GetProvisioner(opts ...ca.ProvisionerOption) (*linkedca.Provisioner, error) + UpdateProvisioner(name string, prov *linkedca.Provisioner) error + RemoveProvisioner(opts ...ca.ProvisionerOption) error +} + +func newCRUDClient(cliCtx *cli.Context, cfgFile string) (crudClient, error) { + unauthAdminClient, err := cautils.NewUnauthenticatedAdminClient(cliCtx) + if err != nil { + return nil, fmt.Errorf("error generating admin client: %w", err) + } + + var netErr *net.OpError + + err = unauthAdminClient.IsEnabled() + switch { + case errors.As(err, &netErr) || errors.Is(err, ca.ErrAdminAPINotImplemented): + ui.PrintSelected("CA Configuration", cfgFile) + cfg, err := config.LoadConfiguration(cfgFile) + if err != nil { + return nil, fmt.Errorf("error loading configuration: %w", err) + } + // Assume the ca.json is already valid to avoid enabling all the + // features present in step-ca just to modify the provisioners. + cfg.SkipValidation = true + + ui.Println() + return newCaConfigClient(context.Background(), cfg, cfgFile) + case errors.Is(err, ca.ErrAdminAPINotAuthorized): + return cautils.NewAdminClient(cliCtx) + default: + return nil, err + } +} + +func parseInstanceAge(ctx *cli.Context) (age string, err error) { + if !ctx.IsSet("instance-age") { + return + } + age = ctx.String("instance-age") + dur, err := time.ParseDuration(age) + if err != nil { + return "", err + } + if dur < 0 { + return "", errs.MinSizeFlag(ctx, "instance-age", "0s") + } + return +} + +func removeElements(list, rems []string) []string { + if len(list) == 0 { + return list + } + for _, rem := range rems { + for i, elem := range list { + if elem == rem { + list[i] = list[len(list)-1] + list = list[:len(list)-1] + break + } + } + } + return list +} + +var ( + x509TemplateFlag = cli.StringFlag{ + Name: "x509-template", + Usage: `The x509 certificate template , a JSON representation of the certificate to create.`, + } + x509TemplateDataFlag = cli.StringFlag{ + Name: "x509-template-data", + Usage: `The x509 certificate template data , a JSON map of data that can be used by the certificate template.`, + } + sshTemplateFlag = cli.StringFlag{ + Name: "ssh-template", + Usage: `The x509 certificate template , a JSON representation of the certificate to create.`, + } + sshTemplateDataFlag = cli.StringFlag{ + Name: "ssh-template-data", + Usage: `The ssh certificate template data , a JSON map of data that can be used by the certificate template.`, + } + x509MinDurFlag = cli.DurationFlag{ + Name: "x509-min-dur", + Usage: `The minimum for an x509 certificate generated by this provisioner. +Value must be a sequence of decimal numbers, each with optional fraction, and a +unit suffix, such as "300ms", "-1.5h" or "2h45m". Valid time units are "ns", +"us" (or "μs"), "ms", "s", "m", "h".`, + } + x509MaxDurFlag = cli.DurationFlag{ + Name: "x509-max-dur", + Usage: `The maximum for an x509 certificate generated by this provisioner. +Value must be a sequence of decimal numbers, each with optional fraction, and a +unit suffix, such as "300ms", "-1.5h" or "2h45m". Valid time units are "ns", +"us" (or "μs"), "ms", "s", "m", "h".`, + } + x509DefaultDurFlag = cli.DurationFlag{ + Name: "x509-default-dur", + Usage: `The default for an x509 certificate generated by this provisioner. +Value must be a sequence of decimal numbers, each with optional fraction, and a +unit suffix, such as "300ms", "-1.5h" or "2h45m". Valid time units are "ns", +"us" (or "μs"), "ms", "s", "m", "h".`, + } + sshUserMinDurFlag = cli.DurationFlag{ + Name: "ssh-user-min-dur", + Usage: `The minimum for an ssh user certificate generated by this provisioner. +Value must be a sequence of decimal numbers, each with optional fraction, and a +unit suffix, such as "300ms", "-1.5h" or "2h45m". Valid time units are "ns", +"us" (or "μs"), "ms", "s", "m", "h".`, + } + sshUserMaxDurFlag = cli.DurationFlag{ + Name: "ssh-user-max-dur", + Usage: `The maximum for an ssh user certificate generated by this provisioner. +Value must be a sequence of decimal numbers, each with optional fraction, and a +unit suffix, such as "300ms", "-1.5h" or "2h45m". Valid time units are "ns", +"us" (or "μs"), "ms", "s", "m", "h".`, + } + sshUserDefaultDurFlag = cli.DurationFlag{ + Name: "ssh-user-default-dur", + Usage: `The maximum for an ssh user certificate generated by this provisioner. +Value must be a sequence of decimal numbers, each with optional fraction, and a +unit suffix, such as "300ms", "-1.5h" or "2h45m". Valid time units are "ns", +"us" (or "μs"), "ms", "s", "m", "h".`, + } + sshHostMinDurFlag = cli.DurationFlag{ + Name: "ssh-host-min-dur", + Usage: `The minimum for an ssh host certificate generated by this provisioner. +Value must be a sequence of decimal numbers, each with optional fraction, and a +unit suffix, such as "300ms", "-1.5h" or "2h45m". Valid time units are "ns", +"us" (or "μs"), "ms", "s", "m", "h".`, + } + sshHostMaxDurFlag = cli.DurationFlag{ + Name: "ssh-host-max-dur", + Usage: `The maximum for an ssh host certificate generated by this provisioner. +Value must be a sequence of decimal numbers, each with optional fraction, and a +unit suffix, such as "300ms", "-1.5h" or "2h45m". Valid time units are "ns", +"us" (or "μs"), "ms", "s", "m", "h".`, + } + sshHostDefaultDurFlag = cli.DurationFlag{ + Name: "ssh-host-default-dur", + Usage: `The maximum for an ssh host certificate generated by this provisioner. +Value must be a sequence of decimal numbers, each with optional fraction, and a +unit suffix, such as "300ms", "-1.5h" or "2h45m". Valid time units are "ns", +"us" (or "μs"), "ms", "s", "m", "h".`, + } + disableRenewalFlag = cli.BoolFlag{ + Name: "disable-renewal", + Usage: `Disable renewal for all certificates generated by this provisioner.`, + } + allowRenewalAfterExpiryFlag = cli.BoolFlag{ + Name: "allow-renewal-after-expiry", + Usage: `Allow renewals for expired certificates generated by this provisioner.`, + } + disableSmallstepExtensionsFlag = cli.BoolFlag{ + Name: "disable-smallstep-extensions", + Usage: `Disable the Smallstep extension for all certificates generated by this provisioner.`, + } + //enableX509Flag = cli.BoolFlag{ + // Name: "x509", + // Usage: `Enable provisioning of x509 certificates.`, + //} + enableSSHFlag = cli.BoolFlag{ + Name: "ssh", + Usage: `Enable provisioning of ssh certificates. The default value is true. To +disable ssh use '--ssh=false'.`, + } + + // General provisioner flags + typeFlag = cli.StringFlag{ + Name: "type", + Value: provisioner.TypeJWK.String(), + Usage: `The of provisioner to create. + +: is a case-insensitive string and must be one of: + +**JWK** +: Uses an JWK key pair to sign provisioning tokens. (default) + +**OIDC** +: Uses an OpenID Connect provider to sign provisioning tokens. + +**AWS** +: Uses Amazon AWS instance identity documents. + +**GCP** +: Use Google instance identity tokens. + +**Azure** +: Uses Microsoft Azure identity tokens. + +**ACME** +: Uses the ACME protocol to create certificates. + +**X5C** +: Uses an X509 certificate / private key pair to sign provisioning tokens. + +**K8SSA** +: Uses Kubernetes Service Account tokens. + +**SSHPOP** +: Uses an SSH certificate / private key pair to sign provisioning tokens. + +**SCEP** +: Uses the SCEP protocol to create certificates. + +**Nebula** +: Uses a Nebula certificate / private key pair to sign provisioning tokens. +`} + nameFlag = cli.StringFlag{ + Name: "name", + Usage: `The new for the provisioner.`, + } + pubKeyFlag = cli.StringFlag{ + Name: "public-key", + Usage: `The containing the JWK public key. Or, a +containing one or more PEM formatted keys, if used with the K8SSA provisioner.`, + } + + // ACME and SCEP provisioner flags + forceCNFlag = cli.BoolFlag{ + Name: "force-cn", + Usage: `Always set the common name in provisioned certificates.`, + } + + challengeFlag = cli.StringSliceFlag{ + Name: "challenge", + Usage: `With a SCEP provisioner the is a shared secret between a +client and the CA. + +With an ACME provisioner, this flag specifies the or challenges to +enable. Use the flag multiple times to configure multiple challenges. + +The supported ACME challenges are: + +**http-01** +: With the HTTP challenge, the client in an ACME transaction proves its control +over a domain name by proving that it can provision HTTP resources on a server +accessible under that domain name. + +**dns-01** +: With the DNS challenge, the client can prove control of a domain by +provisioning a TXT resource record containing a designated value for a specific +validation domain name. + +**tls-alpn-01** +: With the TLS with Application-Layer Protocol Negotiation (TLS ALPN) challenge, +the client can prove control over a domain name by configuring a TLS server to +respond to specific connection attempts using the ALPN extension with +identifying information. + +**device-attest-01** +: With the device attestation challenge, the client can prove control over a +permanent identifier of a device by providing an attestation statement +containing the identifier of the device. + +If the provisioner has no challenges configured, http-01, dns-01 and tls-alpn-01 +will be automatically enabled.`, + } + + removeChallengeFlag = cli.StringSliceFlag{ + Name: "remove-challenge", + Usage: `Remove an ACME from the list configured in the provisioner. +Use the flag multiple times to remove multiple challenges.`, + } + + // ACME provisioner flags + requireEABFlag = cli.BoolFlag{ + Name: "require-eab", + Usage: `Require (and enable) External Account Binding (EAB) for Account creation. +If this flag is set to false, then disable EAB.`, + } + + attestationFormatFlag = cli.StringSliceFlag{ + Name: "attestation-format", + Usage: `Enable an ACME attestation statement in the provisioner. Use the flag +multiple times to configure multiple challenges. + +The supported ACME attestation formats are: + +**apple** +: With the apple format, Apple devices can use the device-attest-01 challenge to +get a new certificate. + +**step** +: With the step format, devices like YubiKeys that can generate an attestation +certificate can use the device-attest-01 challenge to get a new certificate. + +**tpm** +: With the tpm format, devices with TPMs can use the device-attest-01 challenge +to get a new certificate.`, + } + + attestationRootsFlag = cli.StringSliceFlag{ + Name: "attestation-roots", + Usage: `PEM-formatted root certificate(s) used to validate the attestation +certificates. Use the flag multiple times to read from multiple files.`, + } + + removeAttestationFormatFlag = cli.StringSliceFlag{ + Name: "remove-attestation-format", + Usage: `Remove an ACME attestation statement from the list configured in the provisioner. +Use the flag multiple times to remove multiple formats.`, + } + + // SCEP provisioner flags + scepCapabilitiesFlag = cli.StringSliceFlag{ + Name: "capabilities", + Usage: `The SCEP to advertise`, + } + scepIncludeRootFlag = cli.BoolFlag{ + Name: "include-root", + Usage: `Include the CA root certificate in the SCEP CA certificate chain`, + } + scepExcludeIntermediateFlag = cli.BoolFlag{ + Name: "exclude-intermediate", + Usage: `Exclude the CA intermediate certificate in the SCEP CA certificate chain`, + } + scepMinimumPublicKeyLengthFlag = cli.IntFlag{ + Name: "min-public-key-length", + Usage: `The minimum public key of the SCEP RSA encryption key`, + } + scepEncryptionAlgorithmIdentifierFlag = cli.IntFlag{ + Name: "encryption-algorithm-identifier", + Usage: `The for the SCEP encryption algorithm to use. + Valid values are 0 - 4, inclusive. The values correspond to: + 0: DES-CBC, + 1: AES-128-CBC, + 2: AES-256-CBC, + 3: AES-128-GCM, + 4: AES-256-GCM. + Defaults to DES-CBC (0) for legacy clients.`, + } + + scepDecrypterCertFileFlag = cli.StringFlag{ + Name: "scep-decrypter-certificate-file", + Usage: `The path to a PEM certificate for the SCEP decrypter`, + } + scepDecrypterKeyFileFlag = cli.StringFlag{ + Name: "scep-decrypter-key-file", + Usage: `The path to a PEM private key for the SCEP decrypter`, + } + scepDecrypterKeyURIFlag = cli.StringFlag{ + Name: "scep-decrypter-key-uri", + Usage: `The key for the SCEP decrypter. Should be a valid value for the KMS type used.`, + } + scepDecrypterKeyPasswordFileFlag = cli.StringFlag{ + Name: "scep-decrypter-key-password-file", + Usage: `The path to a containing the password for the SCEP decrypter key`, + } + + // Cloud provisioner flags + awsAccountFlag = cli.StringSliceFlag{ + Name: "aws-account", + Usage: `The AWS account used to validate the identity documents. +Use the flag multiple times to configure multiple accounts.`, + } + removeAWSAccountFlag = cli.StringSliceFlag{ + Name: "remove-aws-account", + Usage: `Remove an AWS account used to validate the identity documents. +Use the flag multiple times to remove multiple accounts.`, + } + azureTenantFlag = cli.StringFlag{ + Name: "azure-tenant", + Usage: `The Microsoft Azure tenant used to validate the identity tokens.`, + } + azureResourceGroupFlag = cli.StringSliceFlag{ + Name: "azure-resource-group", + Usage: `The Microsoft Azure resource group used to validate the identity tokens. +Use the flag multiple times to configure multiple resource groups`, + } + removeAzureResourceGroupFlag = cli.StringSliceFlag{ + Name: "remove-azure-resource-group", + Usage: `Remove a Microsoft Azure resource group used to validate the identity tokens. +Use the flag multiple times to configure multiple resource groups`, + } + azureAudienceFlag = cli.StringFlag{ + Name: "azure-audience", + Usage: `The Microsoft Azure audience used to validate the identity tokens.`, + } + azureSubscriptionIDFlag = cli.StringSliceFlag{ + Name: "azure-subscription-id", + Usage: `The Microsoft Azure subscription used to validate the identity tokens. +Use the flag multiple times to configure multiple subscription IDs`, + } + removeAzureSubscriptionIDFlag = cli.StringSliceFlag{ + Name: "remove-azure-subscription-id", + Usage: `Remove a Microsoft Azure subscription used to validate the identity tokens. +Use the flag multiple times to configure multiple subscription IDs`, + } + azureObjectIDFlag = cli.StringSliceFlag{ + Name: "azure-object-id", + Usage: `The Microsoft Azure AD object used to validate the identity tokens. +Use the flag multiple times to configure multiple object IDs`, + } + removeAzureObjectIDFlag = cli.StringSliceFlag{ + Name: "remove-azure-object-id", + Usage: `Remove a Microsoft Azure AD object used to validate the identity tokens. +Use the flag multiple times to remove multiple object IDs`, + } + gcpServiceAccountFlag = cli.StringSliceFlag{ + Name: "gcp-service-account", + Usage: `The Google service account or used to validate the identity tokens. +Use the flag multiple times to configure multiple service accounts.`, + } + removeGCPServiceAccountFlag = cli.StringSliceFlag{ + Name: "remove-gcp-service-account", + Usage: `Remove a Google service account or used to validate the identity tokens. +Use the flag multiple times to remove multiple service accounts.`, + } + gcpProjectFlag = cli.StringSliceFlag{ + Name: "gcp-project", + Usage: `The Google project used to validate the identity tokens. +Use the flag multiple times to configure multiple projects`, + } + removeGCPProjectFlag = cli.StringSliceFlag{ + Name: "remove-gcp-project", + Usage: `Remove a Google project used to validate the identity tokens. +Use the flag multiple times to remove multiple projects`, + } + gcpOrganizationFlag = cli.StringFlag{ + Name: "gcp-organization", + Usage: `The Google organization used to validate the project in the identity tokens.`, + } + instanceAgeFlag = cli.DurationFlag{ + Name: "instance-age", + Usage: `The maximum to grant a certificate in AWS and GCP provisioners. +A is sequence of decimal numbers, each with optional fraction and a +unit suffix, such as "300ms", "-1.5h" or "2h45m". Valid time units are "ns", +"us" (or "μs"), "ms", "s", "m", "h".`, + } + /* + awsIIDRootsFlag = cli.StringFlag{ + Name: "iid-roots", + Usage: `The containing the certificates used to validate the + instance identity documents in AWS.`, + } + */ + disableCustomSANsFlag = cli.BoolFlag{ + Name: "disable-custom-sans", + Usage: `On cloud provisioners, if enabled only the internal DNS and IP will be added as a SAN. +By default it will accept any SAN in the CSR.`, + } + disableTOFUFlag = cli.BoolFlag{ + Name: "disable-trust-on-first-use,disable-tofu", + Usage: `On cloud provisioners, if enabled multiple sign request for this provisioner +with the same instance will be accepted. By default only the first request +will be accepted.`, + } + + disableSSHCAUserFlag = cli.BoolFlag{ + Name: "disable-ssh-ca-user", + Usage: `Disable ability to sign SSH user certificates`, + } + + disableSSHCAHostFlag = cli.BoolFlag{ + Name: "disable-ssh-ca-host", + Usage: `Disable ability to sign SSH host certificates`, + } + + // Nebula provisioner flags + nebulaRootFlag = cli.StringFlag{ + Name: "nebula-root", + Usage: `Root certificate (chain) used to validate the signature on Nebula +provisioning tokens.`, + } + + // JWK provisioner flags + jwkCreateFlag = cli.BoolFlag{ + Name: "create", + Usage: `Create the JWK key pair for the provisioner.`, + } + jwkPrivKeyFlag = cli.StringFlag{ + Name: "private-key", + Usage: `The containing the JWK private key.`, + } + + // OIDC provisioner flags + oidcClientIDFlag = cli.StringFlag{ + Name: "client-id", + Usage: `The used to validate the audience in an OpenID Connect token.`, + } + oidcClientSecretFlag = cli.StringFlag{ + Name: "client-secret", + Usage: `The used to obtain the OpenID Connect tokens.`, + } + oidcListenAddressFlag = cli.StringFlag{ + Name: "listen-address", + Usage: `The callback
used in the OpenID Connect flow (e.g. \":10000\")`, + } + oidcConfigEndpointFlag = cli.StringFlag{ + Name: "configuration-endpoint", + Usage: `OpenID Connect configuration .`, + } + oidcAdminFlag = cli.StringSliceFlag{ + Name: "admin", + Usage: `The of an admin user in an OpenID Connect provisioner, this user +will not have restrictions in the certificates to sign. Use the +'--admin' flag multiple times to configure multiple administrators.`, + } + oidcRemoveAdminFlag = cli.StringSliceFlag{ + Name: "remove-admin", + Usage: `Remove the of an admin user in an OpenID Connect provisioner, this user +will not have restrictions in the certificates to sign. Use the +'--remove-admin' flag multiple times to remove multiple administrators.`, + } + oidcDomainFlag = cli.StringSliceFlag{ + Name: "domain", + Usage: `The used to validate the email claim in an OpenID Connect provisioner. +Use the '--domain' flag multiple times to configure multiple domains.`, + } + oidcRemoveDomainFlag = cli.StringSliceFlag{ + Name: "remove-domain", + Usage: `Remove the used to validate the email claim in an OpenID Connect provisioner. +Use the '--remove-domain' flag multiple times to remove multiple domains.`, + } + oidcGroupFlag = cli.StringSliceFlag{ + Name: "group", + Usage: `The list used to validate the groups extension in an OpenID Connect token. +Use the '--group' flag multiple times to configure multiple groups.`, + } + oidcTenantIDFlag = cli.StringFlag{ + Name: "tenant-id", + Usage: `The used to replace the templatized tenantid value in the OpenID Configuration.`, + } + oidcScopeFlag = cli.StringSliceFlag{ + Name: "scope", + Usage: `The list used to validate the scopes extension in an OpenID Connect token. +Use the '--scope' flag multiple times to configure multiple scopes.`, + } + oidcRemoveScopeFlag = cli.StringSliceFlag{ + Name: "remove-scope", + Usage: `Remove the used to validate the scopes extension in an OpenID Connect token. +Use the '--remove-scope' flag multiple times to remove multiple scopes.`, + } + oidcAuthParamFlag = cli.StringSliceFlag{ + Name: "auth-param", + Usage: `The list used to validate the auth-params extension in an OpenID Connect token. +Use the '--auth-param' flag multiple times to configure multiple auth-params.`, + } + + // X5C provisioner flags + x5cRootsFlag = cli.StringFlag{ + Name: "x5c-roots, x5c-root", + Usage: `PEM-formatted root certificate(s) used to validate the signature on X5C +provisioning tokens.`, + } +) + +func readNebulaRoots(rootFile string) ([][]byte, error) { + b, err := utils.ReadFile(rootFile) + if err != nil { + return nil, err + } + + var crt nebula.Certificate + var certs []nebula.Certificate + for len(b)> 0 { + crt, b, err = nebula.UnmarshalCertificateFromPEM(b) + if err != nil { + return nil, errors.Wrapf(err, "error reading %s", rootFile) + } + if crt.IsCA() { + certs = append(certs, crt) + } + } + if len(certs) == 0 { + return nil, errors.Errorf("error reading %s: no CA certificates found", rootFile) + } + + rootBytes := make([][]byte, len(certs)) + for i, crt := range certs { + b, err = crt.MarshalPEM() + if err != nil { + return nil, errors.Wrap(err, "error marshaling certificate") + } + rootBytes[i] = b + } + + return rootBytes, nil +} diff --git a/command/ca/provisioner/provisioner_test.go b/command/ca/provisioner/provisioner_test.go new file mode 100644 index 00000000..fe0168bf --- /dev/null +++ b/command/ca/provisioner/provisioner_test.go @@ -0,0 +1,97 @@ +package provisioner + +import ( + "crypto/ed25519" + "crypto/rand" + "net/netip" + "os" + "testing" + "time" + + nebula "github.com/slackhq/nebula/cert" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReadNebulaRoots(t *testing.T) { + t.Run("ok", func(t *testing.T) { + tempDir := t.TempDir() + ca, _ := mustNebulaCurve25519CA(t) + file, _ := serializeAndWriteNebulaCert(t, tempDir, ca) + + roots, err := readNebulaRoots(file) + assert.NoError(t, err) + assert.Len(t, roots, 1) + }) + + t.Run("fail/reading", func(t *testing.T) { + roots, err := readNebulaRoots("non-existing-file") + assert.Error(t, err) + assert.Empty(t, roots) + }) + + t.Run("fail/invalid-pem", func(t *testing.T) { + tempDir := t.TempDir() + + file, err := os.CreateTemp(tempDir, "nebula-test-cert-*") + require.NoError(t, err) + defer file.Close() + + _, err = file.Write([]byte{0}) + require.NoError(t, err) + + roots, err := readNebulaRoots(file.Name()) + assert.Error(t, err) + assert.Empty(t, roots) + }) + + t.Run("fail/no-certificates", func(t *testing.T) { + tempDir := t.TempDir() + + file, err := os.CreateTemp(tempDir, "nebula-test-cert-*") + require.NoError(t, err) + defer file.Close() + + roots, err := readNebulaRoots(file.Name()) + assert.Error(t, err) + assert.Empty(t, roots) + }) +} + +func mustNebulaCurve25519CA(t *testing.T) (nebula.Certificate, ed25519.PrivateKey) { + t.Helper() + + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + tbs := &nebula.TBSCertificate{ + Version: nebula.Version1, + Name: "TestCA", + Groups: []string{"test"}, + Networks: []netip.Prefix{netip.MustParsePrefix("10.1.0.0/16")}, + NotBefore: time.Now().Add(-1 * time.Minute), + NotAfter: time.Now().Add(10 * time.Minute), + PublicKey: pub, + IsCA: true, + Curve: nebula.Curve_CURVE25519, + } + nc, err := tbs.Sign(nil, nebula.Curve_CURVE25519, priv) + require.NoError(t, err) + + return nc, priv +} + +func serializeAndWriteNebulaCert(t *testing.T, tempDir string, cert nebula.Certificate) (string, []byte) { + file, err := os.CreateTemp(tempDir, "nebula-test-cert-*") + require.NoError(t, err) + defer file.Close() + + pem, err := cert.MarshalPEM() + require.NoError(t, err) + data, err := cert.Marshal() + require.NoError(t, err) + _, err = file.Write(pem) + require.NoError(t, err) + + return file.Name(), data +} diff --git a/command/ca/provisioner/remove.go b/command/ca/provisioner/remove.go index 8aec9719..f4d0855e 100644 --- a/command/ca/provisioner/remove.go +++ b/command/ca/provisioner/remove.go @@ -1,211 +1,61 @@ package provisioner import ( - "strings" - - "github.com/pkg/errors" - "github.com/smallstep/certificates/authority/config" - "github.com/smallstep/certificates/authority/provisioner" - "github.com/smallstep/cli/ui" "github.com/urfave/cli" - "go.step.sm/cli-utils/errs" + + "github.com/smallstep/certificates/ca" + "github.com/smallstep/cli-utils/errs" + "github.com/smallstep/cli/flags" ) func removeCommand() cli.Command { return cli.Command{ Name: "remove", Action: cli.ActionFunc(removeAction), - Usage: "remove one, or more, provisioners from the CA configuration", + Usage: "remove a provisioner from the CA configuration", UsageText: `**step ca provisioner remove** -[**--kid**=] [**--config**=] [**--all**]`, +[**--admin-cert**=] [**--admin-key**=] [**--admin-subject**=] +[**--admin-provisioner**=] [**--admin-password-file**=] +[**--ca-url**=] [**--root**=] [**--context**=] [**--ca-config**=]`, Flags: []cli.Flag{ - cli.StringFlag{ - Name: "ca-config", - Usage: "The containing the CA configuration.", - }, - cli.StringFlag{ - Name: "kid", - Usage: "The (Key ID) of the JWK provisioner key to be removed.", - }, - cli.StringFlag{ - Name: "client-id", - Usage: "The (Client ID) of the OIDC provisioner to be removed.", - }, - cli.BoolFlag{ - Name: "all", - Usage: `Remove all provisioners with a given name. Cannot be -used in combination w/ the **--kid** or **--client-id** flag.`, - }, - cli.StringFlag{ - Name: "type", - Usage: `The of provisioner to remove. Type is a case-insensitive string -and must be one of: - **JWK** - : Uses an JWK key pair to sign provisioning tokens. - - **OIDC** - : Uses an OpenID Connect provider to sign provisioning tokens. - - **AWS** - : Uses Amazon AWS instance identity documents. - - **GCP** - : Use Google instance identity tokens. - - **Azure** - : Uses Microsoft Azure identity tokens. - - **ACME** - : Uses ACME protocol. - - **X5C** - : Uses an X509 Certificate / private key pair to sign provisioning tokens. - - **K8sSA** - : Uses Kubernetes Service Account tokens.`, - }, + flags.AdminCert, + flags.AdminKey, + flags.AdminSubject, + flags.AdminProvisioner, + flags.AdminPasswordFile, + flags.CaURL, + flags.Root, + flags.Context, + flags.CaConfig, }, - Description: `**step ca provisioner remove** removes one or more provisioners -from the configuration and writes the new configuration back to the CA config. - -To pick up the new configuration you must SIGHUP (kill -1 ) or restart the -step-ca process. - -## POSITIONAL ARGUMENTS - - -: The name field of the provisioner(s) to be removed. + Description: `**step ca provisioner remove** removes a provisioner from the CA configuration. ## EXAMPLES -Remove all provisioners associated with a given name (max@smallstep.com): -''' -$ step ca provisioner remove max@smallstep.com --all --ca-config ca.json -''' - -Remove the provisioner matching a given name and kid: -''' -$ step ca provisioner remove max@smallstep. --kid 1234 --ca-config ca.json -''' - -Remove the provisioner matching a given name and a client id: -''' -$ step ca provisioner remove Google --ca-config ca.json \ - --client-id 1087160488420-8qt7bavg3qesdhs6it824mhnfgcfe8il.apps.googleusercontent.com -''' - -Remove the cloud identity provisioner given name and a type: +Remove provisioner by name: ''' -$ step ca provisioner remove Amazon --ca-config ca.json --type AWS +$ step ca provisioner remove acme ''' -Remove the ACME provisioner by name: +Remove provisioner from a ca.json that is not in the default location: ''' -$ step ca provisioner remove my-acme-provisioner --type acme -''' - -Remove an X5C provisioner by name: -''' -$ step ca provisioner remove my-x5c-provisioner --type x5c -''' - -Remove a K8sSA provisioner by name: -''' -$ step ca provisioner remove k8sSA-default --type k8sSA +$ step ca provisioner remove acme --ca-config /path/to/ca.json '''`, } } -func removeAction(ctx *cli.Context) error { +func removeAction(ctx *cli.Context) (err error) { if err := errs.NumberOfArguments(ctx, 1); err != nil { return err } - name := ctx.Args().Get(0) - caCfg := ctx.String("ca-config") - all := ctx.Bool("all") - kid := ctx.String("kid") - clientID := ctx.String("client-id") - typ := ctx.String("type") + args := ctx.Args() + name := args.Get(0) - if caCfg == "" { - return errs.RequiredFlag(ctx, "ca-config") - } - - if len(kid)> 0 && len(clientID)> 0 { - return errs.MutuallyExclusiveFlags(ctx, "kid", "client-id") - } - - if all { - if kid != "" { - return errs.MutuallyExclusiveFlags(ctx, "all", "kid") - } - if clientID != "" { - return errs.MutuallyExclusiveFlags(ctx, "all", "client-id") - } - } else if kid == "" && clientID == "" && typ == "" { - return errs.RequiredOrFlag(ctx, "all", "kid", "client-id", "type") - } - - c, err := config.LoadConfiguration(caCfg) + client, err := newCRUDClient(ctx, ctx.String("ca-config")) if err != nil { - return errors.Wrapf(err, "error loading configuration") - } - - var ( - provisioners provisioner.List - found = false - ) - for _, p := range c.AuthorityConfig.Provisioners { - if p.GetName() != name || !isProvisionerType(p, typ) { - provisioners = append(provisioners, p) - continue - } - if !all { - switch pp := p.(type) { - case *provisioner.JWK: - if kid != "" && pp.Key.KeyID != kid { - provisioners = append(provisioners, p) - } - case *provisioner.OIDC: - if clientID != "" && pp.ClientID != clientID { - provisioners = append(provisioners, p) - } - case *provisioner.AWS, *provisioner.Azure, *provisioner.GCP, - *provisioner.ACME, *provisioner.X5C, *provisioner.K8sSA: - // they are filtered by type and name. - default: - continue - } - } - found = true - } - - if !found { - switch { - case kid != "": - return errors.Errorf("no provisioners with name=%s and kid=%s found", name, kid) - case clientID != "": - return errors.Errorf("no provisioners with name=%s and client-id=%s found", name, clientID) - case typ != "": - return errors.Errorf("no provisioners with name=%s and type=%s found", name, typ) - default: - return errors.Errorf("no provisioners with name %s found", name) - } - } - - c.AuthorityConfig.Provisioners = provisioners - if err := c.Save(caCfg); err != nil { return err } - ui.Println("Success! Your `step-ca` config has been updated. To pick up the new configuration SIGHUP (kill -1 ) or restart the step-ca process.") - - return nil -} - -// isProvisionerType returns true if p.GetType() is equal to typ. If typ is -// empty it will always return true. -func isProvisionerType(p provisioner.Interface, typ string) bool { - return typ == "" || strings.EqualFold(typ, p.GetType().String()) + return client.RemoveProvisioner(ca.WithProvisionerName(name)) } diff --git a/command/ca/provisionerbeta/update.go b/command/ca/provisioner/update.go similarity index 50% rename from command/ca/provisionerbeta/update.go rename to command/ca/provisioner/update.go index 76f02df8..98f6cabe 100644 --- a/command/ca/provisionerbeta/update.go +++ b/command/ca/provisioner/update.go @@ -1,29 +1,30 @@ -package provisionerbeta +package provisioner import ( - "bytes" "crypto/ecdsa" "crypto/ed25519" "crypto/rsa" "crypto/x509" - "encoding/json" "encoding/pem" "fmt" "net/url" "os" "github.com/pkg/errors" + "github.com/urfave/cli" + "github.com/smallstep/certificates/ca" - "github.com/smallstep/cli/crypto/pemutil" + "github.com/smallstep/cli-utils/errs" + "github.com/smallstep/cli-utils/ui" + + "github.com/smallstep/linkedca" + "go.step.sm/crypto/jose" + "go.step.sm/crypto/pemutil" + "github.com/smallstep/cli/flags" - "github.com/smallstep/cli/jose" - "github.com/smallstep/cli/ui" + "github.com/smallstep/cli/internal/cast" + "github.com/smallstep/cli/internal/sliceutil" "github.com/smallstep/cli/utils" - "github.com/smallstep/cli/utils/cautils" - "github.com/urfave/cli" - "go.step.sm/cli-utils/errs" - "go.step.sm/linkedca" - "google.golang.org/protobuf/encoding/protojson" ) func updateCommand() cli.Command { @@ -31,140 +32,134 @@ func updateCommand() cli.Command { Name: "update", Action: cli.ActionFunc(updateAction), Usage: "update a provisioner", - UsageText: `**step beta ca provisioner update** [**--public-key**=] + UsageText: `**step ca provisioner update** [**--public-key**=] [**--private-key**=] [**--create**] [**--password-file**=] -[**--admin-cert**=] [**--admin-key**=] [**--admin-provisioner**=] -[**--admin-subject**=] [**--password-file**=] [**--ca-url**=] -[**--root**=] [**--context**=] +[**--admin-cert**=] [**--admin-key**=] [**--admin-subject**=] +[**--admin-provisioner**=] [**--admin-password-file**=] +[**--ca-url**=] [**--root**=] [**--context**=] [**--ca-config**=] +[**--x509-template**=] [**--x509-template-data**=] [**--ssh-template**=] +[**--ssh-template-data**=] ACME -**step beta ca provisioner update** [**--force-cn**] -[**--admin-cert**=] [**--admin-key**=] [**--admin-provisioner**=] -[**--admin-subject**=] [**--password-file**=] [**--ca-url**=] -[**--root**=] [**--context**=] +**step ca provisioner update** [**--force-cn**] [**--require-eab**] +[**--challenge**=] [**--remove-challenge**=] +[**--attestation-format**=] [**--remove-attestation-format**=] +[**--attestation-roots**=] [**--admin-cert**=] [**--admin-key**=] +[**--admin-subject**=] [**--admin-provisioner**=] [**--admin-password-file**=] +[**--ca-url**=] [**--root**=] [**--context**=] [**--ca-config**=] +[**--x509-template**=] [**--x509-template-data**=] OIDC -**step beta ca provisioner update** +**step ca provisioner update** [**--client-id**=] [**--client-secret**=] [**--configuration-endpoint**=] [**--listen-address=
] [**--domain**=] [**--remove-domain**=] [**--group**=] [**--remove-group**=] [**--admin**=]... [**--remove-admin**=]... -[**--admin-cert**=] [**--admin-key**=] [**--admin-provisioner**=] -[**--admin-subject**=] [**--password-file**=] [**--ca-url**=] -[**--root**=] [**--context**=] +[**--scope**=] [**--remove-scope**=] +[**--auth-param**=] [**--remove-auth-param**=] +[**--admin-cert**=] [**--admin-key**=] +[**--admin-subject**=] [**--admin-provisioner**=] [**--admin-password-file**=] +[**--ca-url**=] [**--root**=] [**--context**=] [**--ca-config**=] +[**--x509-template**=] [**--x509-template-data**=] [**--ssh-template**=] +[**--ssh-template-data**=] X5C -**step beta ca provisioner update** **--x5c-root**= -[**--admin-cert**=] [**--admin-key**=] [**--admin-provisioner**=] -[**--admin-subject**=] [**--password-file**=] [**--ca-url**=] -[**--root**=] [**--context**=] +**step ca provisioner update** **--x5c-roots**= +[**--admin-cert**=] [**--admin-key**=] +[**--admin-subject**=] [**--admin-provisioner**=] [**--admin-password-file**=] +[**--ca-url**=] [**--root**=] [**--context**=] [**--ca-config**=] +[**--x509-template**=] [**--x509-template-data**=] [**--ssh-template**=] +[**--ssh-template-data**=] -Kubernetes Service Account +K8SSA (Kubernetes Service Account) -**step beta ca provisioner update** [**--public-key**=] -[**--admin-cert**=] [**--admin-key**=] [**--admin-provisioner**=] -[**--admin-subject**=] [**--password-file**=] [**--ca-url**=] -[**--root**=] [**--context**=] +**step ca provisioner update** [**--public-key**=] +[**--admin-cert**=] [**--admin-key**=] +[**--admin-subject**=] [**--admin-provisioner**=] [**--admin-password-file**=] +[**--ca-url**=] [**--root**=] [**--context**=] [**--ca-config**=] +[**--x509-template**=] [**--x509-template-data**=] IID (AWS/GCP/Azure) -**step beta ca provisioner update** +**step ca provisioner update** [**--aws-account**=]... [**--remove-aws-account**=]... [**--gcp-service-account**=]... [**--remove-gcp-service-account**=]... [**--gcp-project**=]... [**--remove-gcp-project**=]... +[**--gcp-organization**=] [**--azure-tenant**=] [**--azure-resource-group**=] -[**--instance-age**=] [**--iid-roots**=] +[**--azure-audience**=] [**--azure-subscription-id**=] +[**--azure-object-id**=] [**--instance-age**=] [**--disable-custom-sans**] [**--disable-trust-on-first-use**] -[**--admin-cert**=] [**--admin-key**=] [**--admin-provisioner**=] -[**--admin-subject**=] [**--password-file**=] [**--ca-url**=] -[**--root**=] [**--context**=]`, +[**--disable-ssh-ca-user**] [**--disable-ssh-ca-host**] +[**--admin-cert**=] [**--admin-key**=] +[**--admin-subject**=] [**--admin-provisioner**=] [**--admin-password-file**=] +[**--ca-url**=] [**--root**=] [**--context**=] [**--ca-config**=] +[**--x509-template**=] [**--x509-template-data**=] [**--ssh-template**=] +[**--ssh-template-data**=] + +SCEP + +**step ca provisioner update** [**--force-cn**] [**--challenge**=] +[**--capabilities**=] [**--include-root**] [**--exclude-intermediate**] +[**--minimum-public-key-length**=] [**--encryption-algorithm-identifier**=] +[**--scep-decrypter-certificate-file**=] [**--scep-decrypter-key-file**=] +[**--scep-decrypter-key-uri**=] [**--scep-decrypter-key-password-file**=] +[**--admin-cert**=] [**--admin-key**=] [**--admin-subject**=] +[**--admin-provisioner**=] [**--admin-password-file**=] +[**--ca-url**=] [**--root**=] [**--context**=] [**--ca-config**=] +[**--x509-template**=] [**--x509-template-data**=]`, Flags: []cli.Flag{ - cli.StringFlag{ - Name: "name", - Usage: `The new for the provisioner.`, - }, - x509TemplateFlag, - x509TemplateDataFlag, - sshTemplateFlag, - sshTemplateDataFlag, - x509MinDurFlag, - x509MaxDurFlag, - x509DefaultDurFlag, - sshUserMinDurFlag, - sshUserMaxDurFlag, - sshUserDefaultDurFlag, - sshHostMinDurFlag, - sshHostMaxDurFlag, - sshHostDefaultDurFlag, - disableRenewalFlag, - enableX509Flag, - enableSSHFlag, + nameFlag, + pubKeyFlag, // JWK provisioner flags - cli.BoolFlag{ - Name: "create", - Usage: `Create the JWK key pair for the provisioner.`, - }, - cli.StringFlag{ - Name: "private-key", - Usage: `The containing the JWK private key.`, - }, - cli.StringFlag{ - Name: "public-key", - Usage: `The containing the JWK public key.`, - }, + jwkCreateFlag, + jwkPrivKeyFlag, // OIDC provisioner flags - cli.StringFlag{ - Name: "client-id", - Usage: `The used to validate the audience in an OpenID Connect token.`, - }, - cli.StringFlag{ - Name: "client-secret", - Usage: `The used to obtain the OpenID Connect tokens.`, - }, - cli.StringFlag{ - Name: "listen-address", - Usage: `The callback
used in the OpenID Connect flow (e.g. \":10000\")`, - }, - cli.StringFlag{ - Name: "configuration-endpoint", - Usage: `OpenID Connect configuration .`, - }, - cli.StringSliceFlag{ - Name: "admin", - Usage: `The of an admin user in an OpenID Connect provisioner, this user -will not have restrictions in the certificates to sign. Use the -'--admin' flag multiple times to configure multiple administrators.`, - }, - cli.StringSliceFlag{ - Name: "remove-admin", - Usage: `Remove the of an admin user in an OpenID Connect provisioner, this user -will not have restrictions in the certificates to sign. Use the -'--admin' flag multiple times to configure multiple administrators.`, - }, - cli.StringSliceFlag{ - Name: "group", - Usage: `The list used to validate the groups extenstion in an OpenID Connect token. -Use the '--group' flag multiple times to configure multiple groups.`, - }, - cli.StringFlag{ - Name: "tenant-id", - Usage: `The used to replace the templatized {tenantid} in the OpenID Configuration.`, - }, - - // X5C provisioner flags - cli.StringFlag{ - Name: "x5c-root", - Usage: `Root certificate (chain) used to validate the signature on X5C -provisioning tokens.`, - }, + oidcClientIDFlag, + oidcClientSecretFlag, + oidcListenAddressFlag, + oidcConfigEndpointFlag, + oidcAdminFlag, + oidcRemoveAdminFlag, + oidcDomainFlag, + oidcRemoveDomainFlag, + oidcGroupFlag, + oidcTenantIDFlag, + oidcScopeFlag, + oidcRemoveScopeFlag, + oidcAuthParamFlag, + + // X5C Root Flag + x5cRootsFlag, + + // Nebula provisioner flags + nebulaRootFlag, + // ACME provisioner flags - forceCNFlag, + requireEABFlag, // ACME + forceCNFlag, // ACME + SCEP + challengeFlag, // ACME + SCEP + removeChallengeFlag, // ACME + attestationFormatFlag, // ACME + removeAttestationFormatFlag, // ACME + attestationRootsFlag, // ACME + + // SCEP flags + scepCapabilitiesFlag, + scepIncludeRootFlag, + scepExcludeIntermediateFlag, + scepMinimumPublicKeyLengthFlag, + scepEncryptionAlgorithmIdentifierFlag, + scepDecrypterCertFileFlag, + scepDecrypterKeyFileFlag, + scepDecrypterKeyURIFlag, + scepDecrypterKeyPasswordFileFlag, // Cloud provisioner flags awsAccountFlag, @@ -172,23 +167,52 @@ provisioning tokens.`, azureTenantFlag, azureResourceGroupFlag, removeAzureResourceGroupFlag, + azureAudienceFlag, + azureSubscriptionIDFlag, + removeAzureSubscriptionIDFlag, + azureObjectIDFlag, + removeAzureObjectIDFlag, gcpServiceAccountFlag, removeGCPServiceAccountFlag, gcpProjectFlag, removeGCPProjectFlag, + gcpOrganizationFlag, instanceAgeFlag, - iidRootsFlag, disableCustomSANsFlag, disableTOFUFlag, + disableSSHCAUserFlag, + disableSSHCAHostFlag, + + // Claims + x509TemplateFlag, + x509TemplateDataFlag, + sshTemplateFlag, + sshTemplateDataFlag, + x509MinDurFlag, + x509MaxDurFlag, + x509DefaultDurFlag, + sshUserMinDurFlag, + sshUserMaxDurFlag, + sshUserDefaultDurFlag, + sshHostMinDurFlag, + sshHostMaxDurFlag, + sshHostDefaultDurFlag, + disableRenewalFlag, + allowRenewalAfterExpiryFlag, + disableSmallstepExtensionsFlag, + //enableX509Flag, + enableSSHFlag, flags.AdminCert, flags.AdminKey, - flags.AdminProvisioner, flags.AdminSubject, + flags.AdminProvisioner, + flags.AdminPasswordFileNoAlias, flags.PasswordFile, flags.CaURL, flags.Root, flags.Context, + flags.CaConfig, }, Description: `**step ca provisioner update** updates a provisioner in the CA configuration. @@ -201,60 +225,92 @@ provisioning tokens.`, Update a JWK provisioner with newly generated keys and a template for x509 certificates: ''' -step beta ca provisioner update cicd --create --x509-template ./templates/example.tpl +step ca provisioner update cicd --create --x509-template ./templates/example.tpl +''' + +Update a JWK provisioner by removing a previously set template: +''' +step ca provisioner update cicd --x509-template "" ''' Update a JWK provisioner with duration claims: ''' -step beta ca provisioner update cicd --create --x509-min-dur 20m --x509-default-dur 48h --ssh-user-min-dur 17m --ssh-host-default-dur 16h +step ca provisioner update cicd --x509-min-dur 20m --x509-default-dur 48h --ssh-user-min-dur 17m --ssh-host-default-dur 16h ''' Update a JWK provisioner with existing keys: ''' -step beta ca provisioner update jane@doe.com --public-key jwk.pub --private-key jwk.priv +step ca provisioner update jane@doe.com --public-key jwk.pub --private-key jwk.priv ''' Update a JWK provisioner to disable ssh provisioning: ''' -step beta ca provisioner update cicd --ssh=false +step ca provisioner update cicd --ssh=false +''' + +Update a JWK provisioner by removing a previously cached private key: +''' +step ca provisioner update cicd --private-key="" +''' + +Update a JWK provisioner and explicitly select the ca.json to modify: +''' +step ca provisioner update cicd --ssh=false --ca-config /path/to/ca.json ''' Update an OIDC provisioner: ''' -step beta ca provisioner update Google \ +step ca provisioner update Google \ --configuration-endpoint https://accounts.google.com/.well-known/openid-configuration ''' Update an X5C provisioner: ''' -step beta ca provisioner update x5c --x5c-root x5c_ca.crt +step ca provisioner update x5c --x5c-roots x5c_ca.crt ''' Update an ACME provisioner: ''' -step beta ca provisioner update acme --force-cn +step ca provisioner update acme --force-cn --require-eab ''' Update an K8SSA provisioner: ''' -step beta ca provisioner update kube --public-key key.pub --x509-min-duration 30m +step ca provisioner update kube --public-key key.pub --x509-min-duration 30m ''' Update an Azure provisioner: ''' -$ step beta ca provisioner update Azure \ +$ step ca provisioner update Azure \ --azure-resource-group identity --azure-resource-group accounting ''' -Update an GCP provisioner: +Update a GCP provisioner: ''' -$ step beta ca provisioner update Google \ +$ step ca provisioner update Google \ --disable-custom-sans --gcp-project internal --remove-gcp-project public ''' +Remove the GCP project and use an organization id: +''' +$ step ca provisioner update Google \ + --gpc-organization 123456789 --remove-gcp-project internal +''' + +Remove the GCP organization and use a project: +''' +$ step ca provisioner update Google \ + --gpc-organization="" --gcp-project internal +''' + Update an AWS provisioner: ''' -$ step beta ca provisioner update Amazon --disable-custom-sans --disable-trust-on-first-use +$ step ca provisioner update Amazon --disable-custom-sans --disable-trust-on-first-use +''' + +Update a SCEP provisioner: +''' +step ca provisioner update my_scep_provisioner --force-cn '''`, } } @@ -267,8 +323,7 @@ func updateAction(ctx *cli.Context) (err error) { args := ctx.Args() name := args[0] - // Create online client - client, err := cautils.NewAdminClient(ctx) + client, err := newCRUDClient(ctx, ctx.String("ca-config")) if err != nil { return err } @@ -278,6 +333,16 @@ func updateAction(ctx *cli.Context) (err error) { return err } + // Validate challenge flag on scep and acme + if err := validateChallengeFlag(ctx, p.Type); err != nil { + return err + } + + // Validate attestation format flag on acme + if err := validateAttestationFormatFlag(ctx, p.Type); err != nil { + return err + } + if ctx.IsSet("name") { p.Name = ctx.String("name") } @@ -305,7 +370,10 @@ func updateAction(ctx *cli.Context) (err error) { err = updateAzureDetails(ctx, p) case linkedca.Provisioner_GCP: err = updateGCPDetails(ctx, p) - // TODO add SCEP provisioner support. + case linkedca.Provisioner_SCEP: + err = updateSCEPDetails(ctx, p) + case linkedca.Provisioner_NEBULA: + err = updateNebulaDetails(ctx, p) default: return fmt.Errorf("unsupported provisioner type %s", p.Type.String()) } @@ -313,21 +381,7 @@ func updateAction(ctx *cli.Context) (err error) { return err } - if err := client.UpdateProvisioner(name, p); err != nil { - return err - } - - var buf bytes.Buffer - b, err := protojson.Marshal(p) - if err != nil { - return err - } - if err := json.Indent(&buf, b, "", " "); err != nil { - return err - } - fmt.Println(buf.String()) - - return nil + return client.UpdateProvisioner(name, p) } func updateTemplates(ctx *cli.Context, p *linkedca.Provisioner) error { @@ -393,15 +447,22 @@ func updateClaims(ctx *cli.Context, p *linkedca.Provisioner) { if ctx.IsSet("disable-renewal") { p.Claims.DisableRenewal = ctx.Bool("disable-renewal") } - claims := p.Claims + if ctx.IsSet("allow-renewal-after-expiry") { + p.Claims.AllowRenewalAfterExpiry = ctx.Bool("allow-renewal-after-expiry") + } + if ctx.IsSet("disable-smallstep-extensions") { + p.Claims.DisableSmallstepExtensions = ctx.Bool("disable-smallstep-extensions") + } + claims := p.Claims if claims.X509 == nil { claims.X509 = &linkedca.X509Claims{} } xc := claims.X509 - if ctx.IsSet("x509") { - claims.X509.Enabled = ctx.Bool("x509") - } + // TODO for the time being x509 is always enabled. + //if ctx.IsSet("x509") { + // claims.X509.Enabled = ctx.Bool("x509") + //} if xc.Durations == nil { xc.Durations = &linkedca.Durations{} } @@ -454,7 +515,7 @@ func updateClaims(ctx *cli.Context, p *linkedca.Provisioner) { func updateJWKDetails(ctx *cli.Context, p *linkedca.Provisioner) error { data, ok := p.Details.GetData().(*linkedca.ProvisionerDetails_JWK) if !ok { - return errors.New("error casting details to ACME type") + return errors.New("error casting details to JWK type") } details := data.JWK @@ -462,7 +523,7 @@ func updateJWKDetails(ctx *cli.Context, p *linkedca.Provisioner) error { err error password string ) - if passwordFile := ctx.String("password-file"); len(passwordFile)> 0 { + if passwordFile := ctx.String("password-file"); passwordFile != "" { password, err = utils.ReadStringPasswordFromFile(passwordFile) if err != nil { return err @@ -470,8 +531,9 @@ func updateJWKDetails(ctx *cli.Context, p *linkedca.Provisioner) error { } var ( - jwk *jose.JSONWebKey - jwe *jose.JSONWebEncryption + jwk *jose.JSONWebKey + jwe *jose.JSONWebEncryption + removePrivateKey bool ) if ctx.Bool("create") { if ctx.IsSet("public-key") { @@ -491,7 +553,7 @@ func updateJWKDetails(ctx *cli.Context, p *linkedca.Provisioner) error { } else { if ctx.IsSet("public-key") { jwkFile := ctx.String("public-key") - jwk, err = jose.ParseKey(jwkFile) + jwk, err = jose.ReadKey(jwkFile) if err != nil { return errs.FileError(err, jwkFile) } @@ -509,7 +571,9 @@ func updateJWKDetails(ctx *cli.Context, p *linkedca.Provisioner) error { } } - if ctx.IsSet("private-key") { + if ctx.IsSet("private-key") && ctx.String("private-key") == "" { + removePrivateKey = true + } else if ctx.IsSet("private-key") { jwkFile := ctx.String("private-key") b, err := os.ReadFile(jwkFile) if err != nil { @@ -524,7 +588,7 @@ func updateJWKDetails(ctx *cli.Context, p *linkedca.Provisioner) error { // Attempt to parse as decrypted private key. jwe, err = jose.ParseEncrypted(string(b)) if err != nil { - privjwk, err := jose.ParseKey(jwkFile) + privjwk, err := jose.ParseKey(b) if err != nil { return errs.FileError(err, jwkFile) } @@ -534,11 +598,20 @@ func updateJWKDetails(ctx *cli.Context, p *linkedca.Provisioner) error { } // Encrypt JWK - opts := []jose.Option{} + var passbytes []byte if ctx.IsSet("password-file") { - opts = append(opts, jose.WithPasswordFile(ctx.String("password-file"))) + passbytes, err = os.ReadFile(ctx.String("password-file")) + if err != nil { + return errs.FileError(err, ctx.String("password-file")) + } + } else { + passbytes, err = ui.PromptPasswordGenerate("Please enter a password to encrypt the provisioner private key? [leave empty and we'll generate one]", + ui.WithValue(password)) + if err != nil { + return err + } } - jwe, err = jose.EncryptJWK(privjwk, opts...) + jwe, err = jose.EncryptJWK(privjwk, passbytes) if err != nil { return err } @@ -554,7 +627,9 @@ func updateJWKDetails(ctx *cli.Context, p *linkedca.Provisioner) error { details.PublicKey = jwkPubBytes } - if jwe != nil { + if removePrivateKey { + details.EncryptedPrivateKey = nil + } else if jwe != nil { jwePrivStr, err := jwe.CompactSerialize() if err != nil { return errors.Wrap(err, "error serializing JWE") @@ -574,10 +649,42 @@ func updateACMEDetails(ctx *cli.Context, p *linkedca.Provisioner) error { if ctx.IsSet("force-cn") { details.ForceCn = ctx.Bool("force-cn") } + if ctx.IsSet("require-eab") { + details.RequireEab = ctx.Bool("require-eab") + } + if ctx.IsSet("remove-challenge") { + values := acmeChallengeToLinkedca(ctx.StringSlice("remove-challenge")) + details.Challenges = sliceutil.RemoveValues(details.Challenges, values) + } + if ctx.IsSet("challenge") { + values := acmeChallengeToLinkedca(ctx.StringSlice("challenge")) + details.Challenges = append(details.Challenges, values...) + } + if ctx.IsSet("challenge") || ctx.IsSet("remove-challenge") { + details.Challenges = sliceutil.RemoveDuplicates(details.Challenges) + } + if ctx.IsSet("remove-attestation-format") { + values := acmeAttestationFormatToLinkedca(ctx.StringSlice("remove-attestation-format")) + details.AttestationFormats = sliceutil.RemoveValues(details.AttestationFormats, values) + } + if ctx.IsSet("attestation-format") { + values := acmeAttestationFormatToLinkedca(ctx.StringSlice("attestation-format")) + details.AttestationFormats = append(details.AttestationFormats, values...) + } + if ctx.IsSet("attestation-format") || ctx.IsSet("remove-attestation-format") { + details.AttestationFormats = sliceutil.RemoveDuplicates(details.AttestationFormats) + } + if ctx.IsSet("attestation-roots") { + attestationRoots, err := parseCACertificates(ctx.StringSlice("attestation-roots")) + if err != nil { + return err + } + details.AttestationRoots = attestationRoots + } return nil } -func updateSSHPOPDetails(ctx *cli.Context, p *linkedca.Provisioner) error { +func updateSSHPOPDetails(*cli.Context, *linkedca.Provisioner) error { return nil } @@ -587,8 +694,8 @@ func updateX5CDetails(ctx *cli.Context, p *linkedca.Provisioner) error { return errors.New("error casting details to X5C type") } details := data.X5C - if ctx.IsSet("x5c-root") { - x5cRootFile := ctx.String("x5c-root") + if ctx.IsSet("x5c-roots") { + x5cRootFile := ctx.String("x5c-roots") roots, err := pemutil.ReadCertificateBundle(x5cRootFile) if err != nil { return errors.Wrapf(err, "error loading X5C Root certificates from %s", x5cRootFile) @@ -611,6 +718,24 @@ func updateX5CDetails(ctx *cli.Context, p *linkedca.Provisioner) error { return nil } +func updateNebulaDetails(ctx *cli.Context, p *linkedca.Provisioner) error { + data, ok := p.Details.GetData().(*linkedca.ProvisionerDetails_Nebula) + if !ok { + return errors.New("error casting details to Nebula type") + } + + details := data.Nebula + if ctx.IsSet("nebula-root") { + rootBytes, err := readNebulaRoots(ctx.String("nebula-root")) + if err != nil { + return err + } + details.Roots = rootBytes + } + + return nil +} + func updateK8SSADetails(ctx *cli.Context, p *linkedca.Provisioner) error { data, ok := p.Details.GetData().(*linkedca.ProvisionerDetails_K8SSA) if !ok { @@ -703,19 +828,38 @@ func updateOIDCDetails(ctx *cli.Context, p *linkedca.Provisioner) error { } details.ConfigurationEndpoint = ce } + if ctx.IsSet("remove-scope") { + details.Scopes = removeElements(details.Scopes, ctx.StringSlice("remove-scope")) + } + if ctx.IsSet("scope") { + details.Scopes = append(details.Scopes, ctx.StringSlice("scope")...) + } + if ctx.IsSet("remove-auth-param") { + details.AuthParams = removeElements(details.AuthParams, ctx.StringSlice("remove-auth-param")) + } + if ctx.IsSet("auth-param") { + details.AuthParams = append(details.AuthParams, ctx.StringSlice("auth-param")...) + } return nil } func updateAWSDetails(ctx *cli.Context, p *linkedca.Provisioner) error { + if ctx.IsSet("disable-ssh-ca-user") { + return errors.New("flag disable-ssh-ca-user is not supported for AWS IID provisioners") + } + if ctx.IsSet("disable-ssh-ca-host") { + return errors.New("flag disable-ssh-ca-host is not supported for AWS IID provisioners") + } + data, ok := p.Details.GetData().(*linkedca.ProvisionerDetails_AWS) if !ok { - return errors.New("error casting details to OIDC type") + return errors.New("error casting details to AWS type") } details := data.AWS var err error if ctx.IsSet("instance-age") { - details.InstanceAge, err = parseIntaceAge(ctx) + details.InstanceAge, err = parseInstanceAge(ctx) if err != nil { return err } @@ -724,38 +868,60 @@ func updateAWSDetails(ctx *cli.Context, p *linkedca.Provisioner) error { details.DisableCustomSans = ctx.Bool("disable-custom-sans") } if ctx.IsSet("disable-trust-on-first-use") { - details.DisableCustomSans = ctx.Bool("disable-trust-on-first-use") + details.DisableTrustOnFirstUse = ctx.Bool("disable-trust-on-first-use") } if ctx.IsSet("remove-aws-account") { details.Accounts = removeElements(details.Accounts, ctx.StringSlice("remove-aws-account")) } if ctx.IsSet("aws-account") { - details.Accounts = append(details.Accounts, ctx.StringSlice("add-aws-account")...) + details.Accounts = append(details.Accounts, ctx.StringSlice("aws-account")...) } return nil } func updateAzureDetails(ctx *cli.Context, p *linkedca.Provisioner) error { + if ctx.IsSet("disable-ssh-ca-user") { + return errors.New("flag disable-ssh-ca-user is not supported for Azure IID provisioners") + } + if ctx.IsSet("disable-ssh-ca-host") { + return errors.New("flag disable-ssh-ca-host is not supported for Azure IID provisioners") + } + data, ok := p.Details.GetData().(*linkedca.ProvisionerDetails_Azure) if !ok { - return errors.New("error casting details to OIDC type") + return errors.New("error casting details to Azure type") } details := data.Azure if ctx.IsSet("azure-tenant") { details.TenantId = ctx.String("azure-tenant") } + if ctx.IsSet("azure-audience") { + details.Audience = ctx.String("azure-audience") + } if ctx.IsSet("disable-custom-sans") { details.DisableCustomSans = ctx.Bool("disable-custom-sans") } if ctx.IsSet("disable-trust-on-first-use") { - details.DisableCustomSans = ctx.Bool("disable-trust-on-first-use") + details.DisableTrustOnFirstUse = ctx.Bool("disable-trust-on-first-use") } if ctx.IsSet("remove-azure-resource-group") { details.ResourceGroups = removeElements(details.ResourceGroups, ctx.StringSlice("remove-azure-resource-group")) } if ctx.IsSet("azure-resource-group") { - details.ResourceGroups = append(details.ResourceGroups, ctx.StringSlice("add-azure-resource-group")...) + details.ResourceGroups = append(details.ResourceGroups, ctx.StringSlice("azure-resource-group")...) + } + if ctx.IsSet("remove-azure-subscription-id") { + details.SubscriptionIds = removeElements(details.SubscriptionIds, ctx.StringSlice("remove-azure-subscription-id")) + } + if ctx.IsSet("azure-subscription-id") { + details.SubscriptionIds = append(details.SubscriptionIds, ctx.StringSlice("azure-subscription-id")...) + } + if ctx.IsSet("remove-azure-object-id") { + details.ObjectIds = removeElements(details.ObjectIds, ctx.StringSlice("remove-azure-object-id")) + } + if ctx.IsSet("azure-object-id") { + details.ObjectIds = append(details.ObjectIds, ctx.StringSlice("azure-object-id")...) } return nil } @@ -763,13 +929,13 @@ func updateAzureDetails(ctx *cli.Context, p *linkedca.Provisioner) error { func updateGCPDetails(ctx *cli.Context, p *linkedca.Provisioner) error { data, ok := p.Details.GetData().(*linkedca.ProvisionerDetails_GCP) if !ok { - return errors.New("error casting details to OIDC type") + return errors.New("error casting details to GCP type") } details := data.GCP var err error if ctx.IsSet("instance-age") { - details.InstanceAge, err = parseIntaceAge(ctx) + details.InstanceAge, err = parseInstanceAge(ctx) if err != nil { return err } @@ -778,19 +944,102 @@ func updateGCPDetails(ctx *cli.Context, p *linkedca.Provisioner) error { details.DisableCustomSans = ctx.Bool("disable-custom-sans") } if ctx.IsSet("disable-trust-on-first-use") { - details.DisableCustomSans = ctx.Bool("disable-trust-on-first-use") + details.DisableTrustOnFirstUse = ctx.Bool("disable-trust-on-first-use") + } + if ctx.IsSet("disable-ssh-ca-user") { + boolVal := ctx.Bool("disable-ssh-ca-user") + details.DisableSshCaUser = &boolVal + } + if ctx.IsSet("disable-ssh-ca-host") { + boolVal := ctx.Bool("disable-ssh-ca-host") + details.DisableSshCaHost = &boolVal } if ctx.IsSet("remove-gcp-service-account") { details.ServiceAccounts = removeElements(details.ServiceAccounts, ctx.StringSlice("remove-gcp-service-account")) } if ctx.IsSet("gcp-service-account") { - details.ServiceAccounts = append(details.ServiceAccounts, ctx.StringSlice("add-gcp-service-account")...) + details.ServiceAccounts = append(details.ServiceAccounts, ctx.StringSlice("gcp-service-account")...) + } + if ctx.IsSet("gcp-organization") { + details.OrganizationId = ctx.String("gcp-organization") } if ctx.IsSet("remove-gcp-project") { - details.ProjectIds = removeElements(details.ProjectIds, ctx.StringSlice("gcp-project")) + details.ProjectIds = removeElements(details.ProjectIds, ctx.StringSlice("remove-gcp-project")) } if ctx.IsSet("gcp-project") { - details.ProjectIds = append(details.ProjectIds, ctx.StringSlice("add-gcp-project")...) + details.ProjectIds = append(details.ProjectIds, ctx.StringSlice("gcp-project")...) + } + + // Validate configuration + if details.OrganizationId != "" && len(details.ProjectIds)> 0 { + return errs.IncompatibleFlagWithFlag(ctx, "gcp-organization", "gcp-project") + } + + return nil +} + +func updateSCEPDetails(ctx *cli.Context, p *linkedca.Provisioner) error { + data, ok := p.Details.GetData().(*linkedca.ProvisionerDetails_SCEP) + if !ok { + return errors.New("error casting details to SCEP type") + } + details := data.SCEP + + if ctx.IsSet("force-cn") { + details.ForceCn = ctx.Bool("force-cn") } + if ctx.IsSet("challenge") { + details.Challenge = ctx.String("challenge") + } + if ctx.IsSet("capabilities") { + details.Capabilities = ctx.StringSlice("capabilities") + } + if ctx.IsSet("min-public-key-length") { + details.MinimumPublicKeyLength = cast.Int32(ctx.Int("min-public-key-length")) + } + if ctx.IsSet("include-root") { + details.IncludeRoot = ctx.Bool("include-root") + } + if ctx.IsSet("exclude-intermediate") { + details.ExcludeIntermediate = ctx.Bool("exclude-intermediate") + } + if ctx.IsSet("encryption-algorithm-identifier") { + details.EncryptionAlgorithmIdentifier = cast.Int32(ctx.Int("encryption-algorithm-identifier")) + } + + decrypter := details.GetDecrypter() + if decrypter == nil { + decrypter = &linkedca.SCEPDecrypter{} + } + if ctx.IsSet("scep-decrypter-certificate-file") { + decrypterCertificateFile := ctx.String("scep-decrypter-certificate-file") + data, err := parseSCEPDecrypterCertificate(decrypterCertificateFile) + if err != nil { + return fmt.Errorf("failed parsing certificate from %q: %w", decrypterCertificateFile, err) + } + decrypter.Certificate = data + details.Decrypter = decrypter + } + if ctx.IsSet("scep-decrypter-key-uri") { + decrypter.KeyUri = ctx.String("scep-decrypter-key-uri") + details.Decrypter = decrypter + } + if decrypterKeyFile := ctx.String("scep-decrypter-key-file"); decrypterKeyFile != "" { + data, err := readSCEPDecrypterKey(decrypterKeyFile) + if err != nil { + return fmt.Errorf("failed reading decrypter key from %q: %w", decrypterKeyFile, err) + } + decrypter.Key = data + details.Decrypter = decrypter + } + if decrypterKeyPasswordFile := ctx.String("scep-decrypter-key-password-file"); decrypterKeyPasswordFile != "" { + decrypterKeyPassword, err := utils.ReadPasswordFromFile(decrypterKeyPasswordFile) + if err != nil { + return fmt.Errorf("failed reading decrypter key password from %q: %w", decrypterKeyPasswordFile, err) + } + decrypter.KeyPassword = decrypterKeyPassword + details.Decrypter = decrypter + } + return nil } diff --git a/command/ca/provisioner/webhook/add.go b/command/ca/provisioner/webhook/add.go new file mode 100644 index 00000000..d6dda214 --- /dev/null +++ b/command/ca/provisioner/webhook/add.go @@ -0,0 +1,159 @@ +package webhook + +import ( + "fmt" + + "github.com/urfave/cli" + + "github.com/smallstep/cli-utils/errs" + "github.com/smallstep/linkedca" + + "github.com/smallstep/cli/flags" + "github.com/smallstep/cli/utils" +) + +func addCommand() cli.Command { + return cli.Command{ + Name: "add", + Action: cli.ActionFunc(addAction), + Usage: "add a webhook to a provisioner", + UsageText: `**step ca provisioner webhook add** +[**--url**=] [**--kind**=] [**--bearer-token-file**=] +[**--basic-auth-username**=] [**--basic-auth-password-file**=] +[**--disable-tls-client-auth**] [**--cert-type**=] +[**--admin-cert**=] [**--admin-key**=] [**--admin-subject**=] +[**--admin-provisioner**=] [**--admin-password-file**=] +[**--ca-url**=] [**--root**=] [**--context**=] [**--ca-config**=]`, + Flags: []cli.Flag{ + urlFlag, + kindFlag, + bearerTokenFileFlag, + basicAuthUsernameFlag, + basicAuthPasswordFileFlag, + disableTLSClientAuthFlag, + certTypeFlag, + + flags.AdminCert, + flags.AdminKey, + flags.AdminSubject, + flags.AdminProvisioner, + flags.AdminPasswordFile, + flags.CaURL, + flags.Root, + flags.Context, + flags.CaConfig, + }, + Description: `**step ca provisioner webhook add** adds a webhook to a provisioner. + +The command will print the webhook ID and secret that must be used to verify all requests from step CA. + +## POSITIONAL ARGUMENTS + + +: The name of the provisioner. + + +: The name of the webhook. + +## EXAMPLES + +Create a webhook without an Authorization header: +''' +step ca provisioner webhook add my_provisioner my_webhook --url https://example.com +''' + +Create a webhook with a bearer token: +''' +step ca provisioner webhook add my_provisioner my_webhook --url https://example.com --bearer-token-file token.txt +''' + +Create a webhook with basic authentication: +''' +step ca provisioner webhook add my_provisioner my_webhook --url https://example.com --basic-auth-username user --basic-auth-password-file pass.txt +''' + +Create a webhook that will never send a client certificate to the webhook server: +''' +step ca provisioner webhook add my_provisioner my_webhook --url https://example.com --disable-tls-client-auth +''' + +Create a webhook that will only be called when signing x509 certificates: +''' +step ca provisioner webhook add my_provisioner my_webhook --url https://example.com --cert-type X509 +'''`, + } +} + +func addAction(ctx *cli.Context) (err error) { + if err := errs.NumberOfArguments(ctx, 2); err != nil { + return err + } + + args := ctx.Args() + + provisionerName := args.Get(0) + + kind := linkedca.Webhook_Kind(linkedca.Webhook_Kind_value[ctx.String("kind")]) + if kind == linkedca.Webhook_NO_KIND { + kind = linkedca.Webhook_ENRICHING + } + + wh := &linkedca.Webhook{ + Name: args.Get(1), + Url: ctx.String("url"), + Kind: kind, + } + + if ctx.IsSet("bearer-token-file") { + bearerTkn, err := utils.ReadStringPasswordFromFile(ctx.String("bearer-token-file")) + if err != nil { + return err + } + wh.Auth = &linkedca.Webhook_BearerToken{ + BearerToken: &linkedca.BearerToken{ + BearerToken: bearerTkn, + }, + } + } else if ctx.IsSet("basic-auth-username") || ctx.IsSet("basic-auth-password-file") { + var password string + if ctx.IsSet("basic-auth-password-file") { + password, err = utils.ReadStringPasswordFromFile(ctx.String("basic-auth-password-file")) + if err != nil { + return err + } + } + wh.Auth = &linkedca.Webhook_BasicAuth{ + BasicAuth: &linkedca.BasicAuth{ + Username: ctx.String("basic-auth-username"), + Password: password, + }, + } + } + + if ctx.IsSet("disable-tls-client-auth") { + wh.DisableTlsClientAuth = ctx.Bool("disable-tls-client-auth") + } + + if ctx.IsSet("cert-type") { + certType, ok := linkedca.Webhook_CertType_value[ctx.String("cert-type")] + if !ok { + return errs.InvalidFlagValue(ctx, "cert-type", ctx.String("cert-type"), "ALL, X509, and SSH") + } + wh.CertType = linkedca.Webhook_CertType(certType) + } else { + wh.CertType = linkedca.Webhook_ALL + } + + client, err := newCRUDClient(ctx, ctx.String("ca-config")) + if err != nil { + return err + } + + if wh, err = client.CreateProvisionerWebhook(provisionerName, wh); err != nil { + return err + } + + fmt.Printf("Webhook ID: %s\nSecret: %s\n", wh.Id, wh.Secret) + + return nil +} diff --git a/command/ca/provisioner/webhook/remove.go b/command/ca/provisioner/webhook/remove.go new file mode 100644 index 00000000..26b03780 --- /dev/null +++ b/command/ca/provisioner/webhook/remove.go @@ -0,0 +1,65 @@ +package webhook + +import ( + "github.com/urfave/cli" + + "github.com/smallstep/cli-utils/errs" + + "github.com/smallstep/cli/flags" +) + +func removeCommand() cli.Command { + return cli.Command{ + Name: "remove", + Action: cli.ActionFunc(removeAction), + Usage: "remove a webhook from a provisioner", + UsageText: `**step ca provisioner webhook remove** +[**--admin-cert**=] [**--admin-key**=] [**--admin-subject**=] +[**--admin-provisioner**=] [**--admin-password-file**=] +[**--ca-url**=] [**--root**=] [**--context**=] [**--ca-config**=]`, + Flags: []cli.Flag{ + flags.AdminCert, + flags.AdminKey, + flags.AdminSubject, + flags.AdminProvisioner, + flags.AdminPasswordFile, + flags.CaURL, + flags.Root, + flags.Context, + flags.CaConfig, + }, + Description: `**step ca provisioner webhook remove** removes a webhook from a provisioner. + +## POSITIONAL ARGUMENTS + + +: The name of the provisioner. + + +: The name of the webhook. + +## EXAMPLES + +Remove a webhook: +''' +step ca provisioner webhook remove my_provisioner my_webhook +'''`, + } +} + +func removeAction(ctx *cli.Context) (err error) { + if err := errs.NumberOfArguments(ctx, 2); err != nil { + return err + } + + args := ctx.Args() + + provisionerName := args.Get(0) + + client, err := newCRUDClient(ctx, ctx.String("ca-config")) + if err != nil { + return err + } + + return client.DeleteProvisionerWebhook(provisionerName, args.Get(1)) +} diff --git a/command/ca/provisioner/webhook/update.go b/command/ca/provisioner/webhook/update.go new file mode 100644 index 00000000..abcbbdac --- /dev/null +++ b/command/ca/provisioner/webhook/update.go @@ -0,0 +1,175 @@ +package webhook + +import ( + "errors" + "fmt" + + "github.com/urfave/cli" + + "github.com/smallstep/certificates/ca" + "github.com/smallstep/cli-utils/errs" + "github.com/smallstep/linkedca" + + "github.com/smallstep/cli/flags" + "github.com/smallstep/cli/utils" +) + +func updateCommand() cli.Command { + return cli.Command{ + Name: "update", + Action: cli.ActionFunc(updateAction), + Usage: "update a webhook attached to a provisioner", + UsageText: `**step ca provisioner webhook update** +[**--url**=] [**--kind**=] [**--bearer-token-file**=] +[**--basic-auth-username**=] [**--basic-auth-password-file**=] +[**--disable-tls-client-auth**] [**--cert-type**=] +[**--admin-cert**=] [**--admin-key**=] [**--admin-subject**=] +[**--admin-provisioner**=] [**--admin-password-file**=] +[**--ca-url**=] [**--root**=] [**--context**=] [**--ca-config**=]`, + Flags: []cli.Flag{ + // General webhook flags + urlFlag, + kindFlag, + bearerTokenFileFlag, + basicAuthUsernameFlag, + basicAuthPasswordFileFlag, + disableTLSClientAuthFlag, + certTypeFlag, + + flags.AdminCert, + flags.AdminKey, + flags.AdminSubject, + flags.AdminProvisioner, + flags.AdminPasswordFile, + flags.CaURL, + flags.Root, + flags.Context, + flags.CaConfig, + }, + Description: `**step ca provisioner webhook update** updates a webhook attached to a provisioner. + +## POSITIONAL ARGUMENTS + + +: The name of the provisioner. + + +: The name of the webhook. + +## EXAMPLES + +Change a webhook's url: +''' +step ca provisioner webhook update my_provisioner my_webhook --url https://example.com +''' + +Configure a webhook to send a bearer token to the server: +''' +step ca provisioner webhook update my_provisioner my_webhook --bearer-token-file token.txt +''' + +Change the password sent to the webhook with basic authentication: +''' +step ca provisioner webhook update my_provisioner my_webhook --basic-auth-password-file my_pass.txt +''' + +Configure the webhook to be called only when signing x509 certificates, not SSH certificates: +''' +step ca provisioner webhook update my_provisioner my_webhook --cert-type X509 +'''`, + } +} + +func updateAction(ctx *cli.Context) (err error) { + if err := errs.NumberOfArguments(ctx, 2); err != nil { + return err + } + + args := ctx.Args() + + provisionerName := args.Get(0) + + client, err := newCRUDClient(ctx, ctx.String("ca-config")) + if err != nil { + return err + } + + prov, err := client.GetProvisioner(ca.WithProvisionerName(provisionerName)) + if err != nil { + return err + } + var wh *linkedca.Webhook + for _, pwh := range prov.Webhooks { + if pwh.Name == args.Get(1) { + wh = pwh + break + } + } + if wh == nil { + return fmt.Errorf("provisioner %q does not have a webhook with the name %q", provisionerName, args.Get(1)) + } + + if ctx.IsSet("kind") { + kind := linkedca.Webhook_Kind(linkedca.Webhook_Kind_value[ctx.String("kind")]) + if kind == linkedca.Webhook_NO_KIND { + return errors.New("invalid webhook kind") + } + wh.Kind = kind + } + + if ctx.IsSet("url") { + wh.Url = ctx.String("url") + } + + if ctx.IsSet("bearer-token-file") { + bearerTkn, err := utils.ReadStringPasswordFromFile(ctx.String("bearer-token-file")) + if err != nil { + return err + } + wh.Auth = &linkedca.Webhook_BearerToken{ + BearerToken: &linkedca.BearerToken{ + BearerToken: bearerTkn, + }, + } + } else if ctx.IsSet("basic-auth-username") || ctx.IsSet("basic-auth-password-file") { + wba, _ := wh.GetAuth().(*linkedca.Webhook_BasicAuth) + if wba == nil { + wba = &linkedca.Webhook_BasicAuth{ + BasicAuth: &linkedca.BasicAuth{}, + } + } + if wba.BasicAuth == nil { + wba.BasicAuth = &linkedca.BasicAuth{} + } + + if ctx.IsSet("basic-auth-username") { + wba.BasicAuth.Username = ctx.String("basic-auth-username") + } + if ctx.IsSet("basic-auth-password-file") { + password, err := utils.ReadStringPasswordFromFile(ctx.String("basic-auth-password-file")) + if err != nil { + return err + } + wba.BasicAuth.Password = password + } + wh.Auth = wba + } + + if ctx.IsSet("disable-tls-client-auth") { + wh.DisableTlsClientAuth = ctx.Bool("disable-tls-client-auth") + } + + if ctx.IsSet("cert-type") { + certType, ok := linkedca.Webhook_CertType_value[ctx.String("cert-type")] + if !ok { + return errs.InvalidFlagValue(ctx, "cert-type", ctx.String("cert-type"), "ALL, X509, and SSH") + } + wh.CertType = linkedca.Webhook_CertType(certType) + } + + if _, err = client.UpdateProvisionerWebhook(provisionerName, wh); err != nil { + return err + } + + return nil +} diff --git a/command/ca/provisioner/webhook/webhook.go b/command/ca/provisioner/webhook/webhook.go new file mode 100644 index 00000000..b4ac65a2 --- /dev/null +++ b/command/ca/provisioner/webhook/webhook.go @@ -0,0 +1,120 @@ +package webhook + +import ( + "errors" + "fmt" + "os" + + "github.com/urfave/cli" + + "github.com/smallstep/certificates/authority/config" + "github.com/smallstep/certificates/ca" + "github.com/smallstep/cli-utils/errs" + "github.com/smallstep/cli-utils/ui" + "github.com/smallstep/linkedca" + + "github.com/smallstep/cli/utils/cautils" +) + +// Command returns the webhook subcommand. +func Command() cli.Command { + return cli.Command{ + Name: "webhook", + Usage: "create and manage webhooks for a provisioner", + UsageText: "step ca provisioner webhook [arguments] [global-flags] [subcommand-flags]", + Subcommands: cli.Commands{ + addCommand(), + updateCommand(), + removeCommand(), + }, + Description: `**step ca provisioner webhook** command group provides facilities for managing the webhooks attached to a provisioner + +Administrators can attach webhooks to provisioners to retrieve additional data that will be available when rendering certificate templates. +Webhooks can also be used to disallow signing certificates for unknown entities. + +Any data returned from the webhook server will be added to the template context under the path "Webhooks.". +Implementations of webhook servers must conform to the step-ca documentation at https://smallstep.com/docs/step-ca/templates for parsing and verifying request bodies and forming valid response bodies. + +## EXAMPLES + +Add a new webhook to a provisioner: +''' +step ca provisioner webhook add my_provisioner my_webhook --url https://example.com +''' + +Change a webhook's url: +''' +step ca provisioner webhook update my_provisioner my_webhook --url https://example.com +''' + +Remove a webhook: +''' +step ca provisioner webhook remove my_provisioner my_webhook +''' + `, + } +} + +var ( + urlFlag = cli.StringFlag{ + Name: "url", + Usage: `The url of the webhook server.`, + } + kindFlag = cli.StringFlag{ + Name: "kind", + Usage: `The kind of webhook. Default is ENRICHING.`, + } + bearerTokenFileFlag = cli.StringFlag{ + Name: "bearer-token-file", + Usage: `The token to be set in the Authorization header of the request to the webhook server.`, + } + basicAuthUsernameFlag = cli.StringFlag{ + Name: "basic-auth-username", + Usage: `The username portion of the Authorization header of the request to the webhook server when using basic authentication.`, + } + basicAuthPasswordFileFlag = cli.StringFlag{ + Name: "basic-auth-password-file", + Usage: `The password porition of the Authorization header of the request to the webhook server when using basic authentication.`, + } + disableTLSClientAuthFlag = cli.BoolFlag{ + Name: "disable-tls-client-auth", + Usage: `The CA will not send a client certificate when requested by the webhook server.`, + } + certTypeFlag = cli.StringFlag{ + Name: "cert-type", + Usage: `Whether to call this webhook when signing X509 certificates, SSH certificates, or ALL certificates. Default is ALL.`, + } +) + +type crudClient interface { + GetProvisioner(...ca.ProvisionerOption) (*linkedca.Provisioner, error) + CreateProvisionerWebhook(provisionerName string, wh *linkedca.Webhook) (*linkedca.Webhook, error) + UpdateProvisionerWebhook(provisionerName string, wh *linkedca.Webhook) (*linkedca.Webhook, error) + DeleteProvisionerWebhook(provisionerName string, webhookName string) error +} + +func newCRUDClient(cliCtx *cli.Context, cfgFile string) (crudClient, error) { + // os.Stat("") probably returns os.ErrNotExist, but this behavior is + // undocumented so we'll handle this case separately. + if cfgFile == "" { + return cautils.NewAdminClient(cliCtx) + } + + _, err := os.Stat(cfgFile) + switch { + case errors.Is(err, os.ErrNotExist): + return cautils.NewAdminClient(cliCtx) + case err == nil: + ui.PrintSelected("CA Configuration", cfgFile) + cfg, err := config.LoadConfiguration(cfgFile) + if err != nil { + return nil, fmt.Errorf("error loading configuration: %w", err) + } + if cfg.AuthorityConfig.EnableAdmin { + return cautils.NewAdminClient(cliCtx) + } + return nil, errors.New("the admin API must be enabled to use webhooks") + default: + return nil, errs.FileError(err, cfgFile) + } +} diff --git a/command/ca/provisionerbeta/add.go b/command/ca/provisionerbeta/add.go deleted file mode 100644 index 0b7f1c4c..00000000 --- a/command/ca/provisionerbeta/add.go +++ /dev/null @@ -1,720 +0,0 @@ -package provisionerbeta - -import ( - "bytes" - "crypto/ecdsa" - "crypto/ed25519" - "crypto/rsa" - "crypto/x509" - "encoding/json" - "encoding/pem" - "fmt" - "net/url" - "os" - - "github.com/pkg/errors" - "github.com/smallstep/certificates/authority/provisioner" - "github.com/smallstep/cli/crypto/pemutil" - "github.com/smallstep/cli/flags" - "github.com/smallstep/cli/jose" - "github.com/smallstep/cli/ui" - "github.com/smallstep/cli/utils" - "github.com/smallstep/cli/utils/cautils" - "github.com/urfave/cli" - "go.step.sm/cli-utils/errs" - "go.step.sm/linkedca" - "google.golang.org/protobuf/encoding/protojson" -) - -func addCommand() cli.Command { - return cli.Command{ - Name: "add", - Action: cli.ActionFunc(addAction), - Usage: "add a provisioner", - UsageText: `**step beta ca provisioner add** **--type**=JWK [**--public-key**=] -[**--private-key**=] [**--create**] [**--password-file**=] -[**--admin-cert**=] [**--admin-key**=] [**--admin-provisioner**=] -[**--admin-subject**=] [**--password-file**=] [**--ca-url**=] -[**--root**=] [**--context**=] - -**step beta ca provisioner add** **--type**=OIDC -[**--client-id**=] [**--client-secret**=] -[**--configuration-endpoint**=] [**--domain**=] -[**--admin**=]... -[**--admin-cert**=] [**--admin-key**=] [**--admin-provisioner**=] -[**--admin-subject**=] [**--password-file**=] [**--ca-url**=] -[**--root**=] [**--context**=] - - -**step beta ca provisioner add** **--type**=X5C **--x5c-root**= -[**--admin-cert**=] [**--admin-key**=] [**--admin-provisioner**=] -[**--admin-subject**=] [**--password-file**=] [**--ca-url**=] -[**--root**=] [**--context**=] - -**step beta ca provisioner add** **--type**=SSHPOP -[**--admin-cert**=] [**--admin-key**=] [**--admin-provisioner**=] -[**--admin-subject**=] [**--password-file**=] [**--ca-url**=] -[**--root**=] [**--context**=] - -**step beta ca provisioner add** **--type**=K8SSA [**--public-key**=] -[**--admin-cert**=] [**--admin-key**=] [**--admin-provisioner**=] -[**--admin-subject**=] [**--password-file**=] [**--ca-url**=] -[**--root**=] [**--context**=] - -**step beta ca provisioner add** **--type**=[AWS|Azure|GCP] -[**--aws-account**=] [**--gcp-service-account**=] [**--gcp-project**=] -[**--azure-tenant**=] [**--azure-resource-group**=] -[**--instance-age**=] [**--iid-roots**=] -[**--disable-custom-sans**] [**--disable-trust-on-first-use**] -[**--admin-cert**=] [**--admin-key**=] [**--admin-provisioner**=] -[**--admin-subject**=] [**--password-file**=] [**--ca-url**=] -[**--root**=] [**--context**=] - -**step beta ca provisioner add** **--type**=ACME [**--force-cn**] -[**--admin-cert**=] [**--admin-key**=] [**--admin-provisioner**=] -[**--admin-subject**=] [**--password-file**=] [**--ca-url**=] -[**--root**=] [**--context**=]`, - Flags: []cli.Flag{ - cli.StringFlag{ - Name: "type", - Value: provisioner.TypeJWK.String(), - Usage: `The of provisioner to create. - -: is a case-insensitive string and must be one of: - - **JWK** - : Uses an JWK key pair to sign provisioning tokens. (default) - - **OIDC** - : Uses an OpenID Connect provider to sign provisioning tokens. - - **AWS** - : Uses Amazon AWS instance identity documents. - - **GCP** - : Use Google instance identity tokens. - - **Azure** - : Uses Microsoft Azure identity tokens. - - **ACME** - : Uses the ACME protocol to create certificates. - - **X5C** - : Uses an X509 Certificate / private key pair to sign provisioning tokens. - - **K8SSA** - : Uses Kubernetes Service Account tokens. - - **SSHPOP** - : Uses an SSH Certificate / private key pair to sign provisioning tokens.`}, - x509TemplateFlag, - x509TemplateDataFlag, - sshTemplateFlag, - sshTemplateDataFlag, - x509MinDurFlag, - x509MaxDurFlag, - x509DefaultDurFlag, - sshUserMinDurFlag, - sshUserMaxDurFlag, - sshUserDefaultDurFlag, - sshHostMinDurFlag, - sshHostMaxDurFlag, - sshHostDefaultDurFlag, - disableRenewalFlag, - enableX509Flag, - enableSSHFlag, - - // JWK provisioner flags - cli.BoolFlag{ - Name: "create", - Usage: `Create the JWK key pair for the provisioner.`, - }, - cli.StringFlag{ - Name: "private-key", - Usage: `The containing the JWK private key.`, - }, - cli.StringFlag{ - Name: "public-key", - Usage: `The containing the JWK public key. Or, a -containing one or more PEM formatted keys, if used with the K8SSA provisioner.`, - }, - - // OIDC provisioner flags - cli.StringFlag{ - Name: "client-id", - Usage: `The used to validate the audience in an OpenID Connect token.`, - }, - cli.StringFlag{ - Name: "client-secret", - Usage: `The used to obtain the OpenID Connect tokens.`, - }, - cli.StringFlag{ - Name: "listen-address", - Usage: `The callback
used in the OpenID Connect flow (e.g. \":10000\")`, - }, - cli.StringFlag{ - Name: "configuration-endpoint", - Usage: `OpenID Connect configuration .`, - }, - cli.StringSliceFlag{ - Name: "admin", - Usage: `The of an admin user in an OpenID Connect provisioner, this user -will not have restrictions in the certificates to sign. Use the -'--admin' flag multiple times to configure multiple administrators.`, - }, - cli.StringSliceFlag{ - Name: "group", - Usage: `The list used to validate the groups extenstion in an OpenID Connect token. -Use the '--group' flag multiple times to configure multiple groups.`, - }, - cli.StringFlag{ - Name: "tenant-id", - Usage: `The used to replace the templatized {tenantid} in the OpenID Configuration.`, - }, - - // X5C provisioner flags - cli.StringFlag{ - Name: "x5c-root", - Usage: `Root certificate (chain) used to validate the signature on X5C -provisioning tokens.`, - }, - - // ACME provisioner flags - forceCNFlag, - - // Cloud provisioner flags - awsAccountFlag, - azureTenantFlag, - azureResourceGroupFlag, - gcpServiceAccountFlag, - gcpProjectFlag, - instanceAgeFlag, - iidRootsFlag, - disableCustomSANsFlag, - disableTOFUFlag, - - flags.AdminCert, - flags.AdminKey, - flags.AdminProvisioner, - flags.AdminSubject, - flags.PasswordFile, - flags.CaURL, - flags.Root, - flags.Context, - }, - Description: `**step ca provisioner add** adds a provisioner to the CA configuration. - -## POSITIONAL ARGUMENTS - - -: The name of the provisioner. - -## EXAMPLES - -Create a JWK provisioner with newly generated keys and a template for x509 certificates: -''' -step beta ca provisioner add cicd --type JWK --create --x509-template ./templates/example.tpl -''' - -Create a JWK provisioner with duration claims: -''' -step beta ca provisioner add cicd --type JWK --create --x509-min-dur 20m --x509-default-dur 48h --ssh-user-min-dur 17m --ssh-host-default-dur 16h -''' - -Create a JWK provisioner with existing keys: -''' -step beta ca provisioner add jane@doe.com --type JWK --public-key jwk.pub --private-key jwk.priv -''' - -Create an OIDC provisioner: -''' -step beta ca provisioner add Google --type OIDC --ssh \ - --client-id 1087160488420-8qt7bavg3qesdhs6it824mhnfgcfe8il.apps.googleusercontent.com \ - --client-secret udTrOT3gzrO7W9fDPgZQLfYJ \ - --configuration-endpoint https://accounts.google.com/.well-known/openid-configuration -''' - -Create an X5C provisioner: -''' -step beta ca provisioner add x5c --type X5C --x5c-root x5c_ca.crt -''' - -Create an ACME provisioner: -''' -step beta ca provisioner add acme --type ACME -''' - -Create an K8SSA provisioner: -''' -step beta ca provisioner add kube --type K8SSA --ssh --public-key key.pub -''' - -Create an SSHPOP provisioner for renewing SSH host certificates:") -''' -step beta ca provisioner add sshpop --type SSHPOP -''' - -Create an Azure provisioner with two service groups: -''' -$ step beta ca provisioner add Azure --type Azure \ - --azure-tenant bc9043e2-b645-4c1c-a87a-78f8644bfe57 \ - --azure-resource-group identity --azure-resource-group accounting -''' - -Create an GCP provisioner that will only accept the SANs provided in the identity token: -''' -$ step beta ca provisioner add Google --type GCP \ - --disable-custom-sans --gcp-project internal -''' - -Create an AWS provisioner that will only accept the SANs provided in the identity -document and will allow multiple certificates from the same instance: -''' -$ step beta ca provisioner add Amazon --type AWS \ - --aws-account 123456789 --disable-custom-sans --disable-trust-on-first-use -''' - -Create an AWS provisioner that will use a custom certificate to validate the instance -identity documents: -''' -$ step beta ca provisioner add Amazon --type AWS \ - --aws-account 123456789 --iid-roots $(step path)/certs/aws.crt -'''`, - } -} - -func addAction(ctx *cli.Context) (err error) { - if err := errs.NumberOfArguments(ctx, 1); err != nil { - return err - } - - x509TemplateFile := ctx.String("x509-template") - x509TemplateDataFile := ctx.String("x509-template-data") - sshTemplateFile := ctx.String("ssh-template") - sshTemplateDataFile := ctx.String("ssh-template-data") - - args := ctx.Args() - - typ := ctx.String("type") - - p := &linkedca.Provisioner{ - Name: args.Get(0), - } - - // Read x509 template if passed - p.X509Template = &linkedca.Template{} - if x509TemplateFile != "" { - b, err := utils.ReadFile(x509TemplateFile) - if err != nil { - return err - } - p.X509Template.Template = b - } - if x509TemplateDataFile != "" { - b, err := utils.ReadFile(x509TemplateDataFile) - if err != nil { - return err - } - p.X509Template.Data = b - } - // Read ssh template if passed - p.SshTemplate = &linkedca.Template{} - if sshTemplateFile != "" { - b, err := utils.ReadFile(sshTemplateFile) - if err != nil { - return err - } - p.SshTemplate.Template = b - } - if sshTemplateDataFile != "" { - b, err := utils.ReadFile(sshTemplateDataFile) - if err != nil { - return err - } - p.SshTemplate.Data = b - } - - p.Claims = &linkedca.Claims{ - X509: &linkedca.X509Claims{ - Durations: &linkedca.Durations{ - Min: ctx.String("x509-min-dur"), - Max: ctx.String("x509-max-dur"), - Default: ctx.String("x509-default-dur"), - }, - Enabled: !(ctx.IsSet("x509") && !ctx.Bool("x509")), - }, - Ssh: &linkedca.SSHClaims{ - UserDurations: &linkedca.Durations{ - Min: ctx.String("ssh-user-min-dur"), - Max: ctx.String("ssh-user-max-dur"), - Default: ctx.String("ssh-user-default-dur"), - }, - HostDurations: &linkedca.Durations{ - Min: ctx.String("ssh-host-min-dur"), - Max: ctx.String("ssh-host-max-dur"), - Default: ctx.String("ssh-host-default-dur"), - }, - Enabled: !(ctx.IsSet("ssh") && !ctx.Bool("ssh")), - }, - DisableRenewal: ctx.Bool("disable-renewal"), - } - - client, err := cautils.NewAdminClient(ctx) - if err != nil { - return err - } - - switch typ { - case linkedca.Provisioner_JWK.String(): - p.Type = linkedca.Provisioner_JWK - p.Details, err = createJWKDetails(ctx) - case linkedca.Provisioner_ACME.String(): - p.Type = linkedca.Provisioner_ACME - p.Details, err = createACMEDetails(ctx) - case linkedca.Provisioner_SSHPOP.String(): - p.Type = linkedca.Provisioner_SSHPOP - p.Details, err = createSSHPOPDetails(ctx) - case linkedca.Provisioner_X5C.String(): - p.Type = linkedca.Provisioner_X5C - p.Details, err = createX5CDetails(ctx) - case linkedca.Provisioner_K8SSA.String(): - p.Type = linkedca.Provisioner_K8SSA - p.Details, err = createK8SSADetails(ctx) - case linkedca.Provisioner_OIDC.String(): - p.Type = linkedca.Provisioner_OIDC - p.Details, err = createOIDCDetails(ctx) - case linkedca.Provisioner_AWS.String(): - p.Type = linkedca.Provisioner_AWS - p.Details, err = createAWSDetails(ctx) - case linkedca.Provisioner_AZURE.String(): - p.Type = linkedca.Provisioner_AZURE - p.Details, err = createAzureDetails(ctx) - case linkedca.Provisioner_GCP.String(): - p.Type = linkedca.Provisioner_GCP - p.Details, err = createGCPDetails(ctx) - // TODO add SCEP provisioner support. - default: - return fmt.Errorf("unsupported provisioner type %s", typ) - } - if err != nil { - return err - } - - if p, err = client.CreateProvisioner(p); err != nil { - return err - } - - var buf bytes.Buffer - b, err := protojson.Marshal(p) - if err != nil { - return err - } - if err := json.Indent(&buf, b, "", " "); err != nil { - return err - } - fmt.Println(buf.String()) - - return nil -} - -func createJWKDetails(ctx *cli.Context) (*linkedca.ProvisionerDetails, error) { - var ( - err error - password string - ) - if passwordFile := ctx.String("password-file"); len(passwordFile)> 0 { - password, err = utils.ReadStringPasswordFromFile(passwordFile) - if err != nil { - return nil, err - } - } - - var ( - jwk *jose.JSONWebKey - jwe *jose.JSONWebEncryption - ) - if ctx.Bool("create") { - if ctx.IsSet("public-key") { - return nil, errs.IncompatibleFlag(ctx, "create", "public-key") - } - if ctx.IsSet("private-key") { - return nil, errs.IncompatibleFlag(ctx, "create", "private-key") - } - pass, err := ui.PromptPasswordGenerate("Please enter a password to encrypt the provisioner private key? [leave empty and we'll generate one]", ui.WithValue(password)) - if err != nil { - return nil, err - } - jwk, jwe, err = jose.GenerateDefaultKeyPair(pass) - if err != nil { - return nil, err - } - } else { - if !ctx.IsSet("public-key") { - return nil, errs.RequiredWithFlagValue(ctx, "create", "false", "public-key") - } - jwkFile := ctx.String("public-key") - jwk, err = jose.ParseKey(jwkFile) - if err != nil { - return nil, errs.FileError(err, jwkFile) - } - - // Only use asymmetric cryptography - if _, ok := jwk.Key.([]byte); ok { - return nil, errors.New("invalid JWK: a symmetric key cannot be used as a provisioner") - } - // Create kid if not present - if jwk.KeyID == "" { - jwk.KeyID, err = jose.Thumbprint(jwk) - if err != nil { - return nil, err - } - } - - if ctx.IsSet("private-key") { - jwkFile = ctx.String("private-key") - b, err := os.ReadFile(jwkFile) - if err != nil { - return nil, errors.Wrapf(err, "error reading %s", jwkFile) - } - - // Attempt to parse private key as Encrypted JSON. - // If this operation fails then either, - // 1. the key is not encrypted - // 2. the key has an invalid format - // - // Attempt to parse as decrypted private key. - jwe, err = jose.ParseEncrypted(string(b)) - if err != nil { - privjwk, err := jose.ParseKey(jwkFile) - if err != nil { - return nil, errs.FileError(err, jwkFile) - } - - if privjwk.IsPublic() { - return nil, errors.New("invalid jwk: private-key is a public key") - } - - // Encrypt JWK - opts := []jose.Option{} - if ctx.IsSet("password-file") { - opts = append(opts, jose.WithPasswordFile(ctx.String("password-file"))) - } - jwe, err = jose.EncryptJWK(privjwk, opts...) - if err != nil { - return nil, err - } - } - } - } - - jwkPubBytes, err := jwk.MarshalJSON() - if err != nil { - return nil, errors.Wrap(err, "error marshaling JWK") - } - jwkProv := &linkedca.JWKProvisioner{ - PublicKey: jwkPubBytes, - } - - if jwe != nil { - jwePrivStr, err := jwe.CompactSerialize() - if err != nil { - return nil, errors.Wrap(err, "error serializing JWE") - } - jwkProv.EncryptedPrivateKey = []byte(jwePrivStr) - } - - return &linkedca.ProvisionerDetails{ - Data: &linkedca.ProvisionerDetails_JWK{ - JWK: jwkProv, - }, - }, nil -} - -func createACMEDetails(ctx *cli.Context) (*linkedca.ProvisionerDetails, error) { - return &linkedca.ProvisionerDetails{ - Data: &linkedca.ProvisionerDetails_ACME{ - ACME: &linkedca.ACMEProvisioner{ - ForceCn: ctx.Bool("force-cn"), - }, - }, - }, nil -} - -func createSSHPOPDetails(ctx *cli.Context) (*linkedca.ProvisionerDetails, error) { - return &linkedca.ProvisionerDetails{ - Data: &linkedca.ProvisionerDetails_SSHPOP{ - SSHPOP: &linkedca.SSHPOPProvisioner{}, - }, - }, nil -} - -func createX5CDetails(ctx *cli.Context) (*linkedca.ProvisionerDetails, error) { - x5cRootFile := ctx.String("x5c-root") - if x5cRootFile == "" { - return nil, errs.RequiredWithFlagValue(ctx, "type", "x5c", "x5c-root") - } - - roots, err := pemutil.ReadCertificateBundle(x5cRootFile) - if err != nil { - return nil, errors.Wrapf(err, "error loading X5C Root certificates from %s", x5cRootFile) - } - var rootBytes [][]byte - for _, r := range roots { - if r.KeyUsage&x509.KeyUsageCertSign == 0 { - return nil, errors.Errorf("error: certificate with common name '%s' cannot be "+ - "used as an X5C root certificate.\n\n"+ - "X5C provisioner root certificates must have the 'Certificate Sign' key "+ - "usage extension.", r.Subject.CommonName) - } - rootBytes = append(rootBytes, pem.EncodeToMemory(&pem.Block{ - Type: "CERTIFICATE", - Bytes: r.Raw, - })) - } - return &linkedca.ProvisionerDetails{ - Data: &linkedca.ProvisionerDetails_X5C{ - X5C: &linkedca.X5CProvisioner{ - Roots: rootBytes, - }, - }, - }, nil -} - -func createK8SSADetails(ctx *cli.Context) (*linkedca.ProvisionerDetails, error) { - pemKeysF := ctx.String("public-key") - if pemKeysF == "" { - return nil, errs.RequiredWithFlagValue(ctx, "type", "k8sSA", "public-key") - } - - pemKeysB, err := os.ReadFile(pemKeysF) - if err != nil { - return nil, errors.Wrap(err, "error reading pem keys") - } - - var ( - block *pem.Block - rest = pemKeysB - pemKeys = []interface{}{} - ) - for rest != nil { - block, rest = pem.Decode(rest) - if block == nil { - break - } - key, err := pemutil.ParseKey(pem.EncodeToMemory(block)) - if err != nil { - return nil, errors.Wrapf(err, "error parsing public key from %s", pemKeysF) - } - switch q := key.(type) { - case *rsa.PublicKey, *ecdsa.PublicKey, ed25519.PublicKey: - default: - return nil, errors.Errorf("Unexpected public key type %T in %s", q, pemKeysF) - } - pemKeys = append(pemKeys, key) - } - - var pubKeyBytes [][]byte - for _, k := range pemKeys { - blk, err := pemutil.Serialize(k) - if err != nil { - return nil, errors.Wrap(err, "error serializing pem key") - } - pubKeyBytes = append(pubKeyBytes, pem.EncodeToMemory(blk)) - } - return &linkedca.ProvisionerDetails{ - Data: &linkedca.ProvisionerDetails_K8SSA{ - K8SSA: &linkedca.K8SSAProvisioner{ - PublicKeys: pubKeyBytes, - }, - }, - }, nil -} - -func createOIDCDetails(ctx *cli.Context) (*linkedca.ProvisionerDetails, error) { - clientID := ctx.String("client-id") - if clientID == "" { - return nil, errs.RequiredWithFlagValue(ctx, "type", ctx.String("type"), "client-id") - } - - confURL := ctx.String("configuration-endpoint") - if confURL == "" { - return nil, errs.RequiredWithFlagValue(ctx, "type", ctx.String("type"), "configuration-endpoint") - } - u, err := url.Parse(confURL) - if err != nil || (u.Scheme != "https" && u.Scheme != "http") { - return nil, errs.InvalidFlagValue(ctx, "configuration-endpoint", confURL, "") - } - - return &linkedca.ProvisionerDetails{ - Data: &linkedca.ProvisionerDetails_OIDC{ - OIDC: &linkedca.OIDCProvisioner{ - ClientId: clientID, - ClientSecret: ctx.String("client-secret"), - ConfigurationEndpoint: confURL, - Admins: ctx.StringSlice("admin"), - Domains: ctx.StringSlice("domain"), - Groups: ctx.StringSlice("group"), - ListenAddress: ctx.String("listen-address"), - TenantId: ctx.String("tenant-id"), - }, - }, - }, nil -} - -func createAWSDetails(ctx *cli.Context) (*linkedca.ProvisionerDetails, error) { - d, err := parseIntaceAge(ctx) - if err != nil { - return nil, err - } - - return &linkedca.ProvisionerDetails{ - Data: &linkedca.ProvisionerDetails_AWS{ - AWS: &linkedca.AWSProvisioner{ - Accounts: ctx.StringSlice("aws-account"), - DisableCustomSans: ctx.Bool("disable-custom-sans"), - DisableTrustOnFirstUse: ctx.Bool("disable-trust-on-first-use"), - InstanceAge: d, - // TODO IID Roots - // IIDRoots: ctx.String("iid-roots"), - }, - }, - }, nil -} - -func createAzureDetails(ctx *cli.Context) (*linkedca.ProvisionerDetails, error) { - tenantID := ctx.String("azure-tenant") - if tenantID == "" { - return nil, errs.RequiredWithFlagValue(ctx, "type", ctx.String("type"), "azure-tenant") - } - - return &linkedca.ProvisionerDetails{ - Data: &linkedca.ProvisionerDetails_Azure{ - Azure: &linkedca.AzureProvisioner{ - TenantId: tenantID, - ResourceGroups: ctx.StringSlice("azure-resource-group"), - DisableCustomSans: ctx.Bool("disable-custom-sans"), - DisableTrustOnFirstUse: ctx.Bool("disable-trust-on-first-use"), - }, - }, - }, nil -} - -func createGCPDetails(ctx *cli.Context) (*linkedca.ProvisionerDetails, error) { - d, err := parseIntaceAge(ctx) - if err != nil { - return nil, err - } - - return &linkedca.ProvisionerDetails{ - Data: &linkedca.ProvisionerDetails_GCP{ - GCP: &linkedca.GCPProvisioner{ - ServiceAccounts: ctx.StringSlice("gcp-service-account"), - ProjectIds: ctx.StringSlice("gcp-project"), - DisableCustomSans: ctx.Bool("disable-custom-sans"), - DisableTrustOnFirstUse: ctx.Bool("disable-trust-on-first-use"), - InstanceAge: d, - }, - }, - }, nil -} diff --git a/command/ca/provisionerbeta/get.go b/command/ca/provisionerbeta/get.go deleted file mode 100644 index 489bd7e6..00000000 --- a/command/ca/provisionerbeta/get.go +++ /dev/null @@ -1,77 +0,0 @@ -package provisionerbeta - -import ( - "bytes" - "encoding/json" - "fmt" - - "github.com/smallstep/certificates/ca" - "github.com/smallstep/cli/flags" - "github.com/smallstep/cli/utils/cautils" - "github.com/urfave/cli" - "go.step.sm/cli-utils/errs" - "google.golang.org/protobuf/encoding/protojson" -) - -func getCommand() cli.Command { - return cli.Command{ - Name: "get", - Action: cli.ActionFunc(getAction), - Usage: "get a provisioner from the CA configuration", - UsageText: `**step beta ca provisioner get** -[**--admin-cert**=] [**--admin-key**=] [**--admin-provisioner**=] -[**--admin-subject**=] [**--password-file**=] [**--ca-url**=] -[**--root**=] [**--context**=]`, - Flags: []cli.Flag{ - flags.AdminCert, - flags.AdminKey, - flags.AdminProvisioner, - flags.AdminSubject, - flags.PasswordFile, - flags.CaURL, - flags.Root, - flags.Context, - }, - Description: `**step beta ca provisioner get** gets a provisioner from the CA configuration. - -## EXAMPLES - -Get a provisioner by name: -''' -$ step beta ca provisioner get acme -''' -`, - } -} - -func getAction(ctx *cli.Context) (err error) { - if err := errs.NumberOfArguments(ctx, 1); err != nil { - return err - } - - args := ctx.Args() - name := args.Get(0) - - // Create online client - client, err := cautils.NewAdminClient(ctx) - if err != nil { - return err - } - - p, err := client.GetProvisioner(ca.WithProvisionerName(name)) - if err != nil { - return err - } - - var buf bytes.Buffer - b, err := protojson.Marshal(p) - if err != nil { - return err - } - if err := json.Indent(&buf, b, "", " "); err != nil { - return err - } - fmt.Println(buf.String()) - - return nil -} diff --git a/command/ca/provisionerbeta/provisioner.go b/command/ca/provisionerbeta/provisioner.go deleted file mode 100644 index df8f7c51..00000000 --- a/command/ca/provisionerbeta/provisioner.go +++ /dev/null @@ -1,241 +0,0 @@ -package provisionerbeta - -import ( - "time" - - "github.com/urfave/cli" - "go.step.sm/cli-utils/errs" -) - -// Command returns the jwk subcommand. -func Command() cli.Command { - return cli.Command{ - Name: "provisioner", - Usage: "create and manage the certificate authority provisioners", - UsageText: "**step beta ca provisioner** [arguments] [global-flags] [subcommand-flags]", - Subcommands: cli.Commands{ - //listCommand(), - addCommand(), - removeCommand(), - getCommand(), - updateCommand(), - }, - Description: `**step beta ca provisioner** command group provides facilities for managing the -certificate authority provisioners. - -A provisioner is an entity that controls provisioning credentials, which are -used to generate provisioning tokens. - -Provisioning credentials are simple JWK key pairs using public-key cryptography. -The public key is used to verify a provisioning token while the private key is -used to sign the provisioning token. - -Provisioning tokens are JWT tokens signed by the JWK private key. These JWT -tokens are used to get a valid TLS certificate from the certificate authority. -Each provisioner is able to manage a different set of rules that can be used to -configure the bounds of the certificate. - -In the certificate authority, a provisioner is configured with a JSON object -with the following properties: - -* **name**: the provisioner name, it will become the JWT issuer and a good - practice is to use an email address for this. -* **type**: the provisioner type, currently only "jwk" is supported. -* **key**: the JWK public key used to verify the provisioning tokens. -* **encryptedKey** (optional): the JWE compact serialization of the private key - used to sign the provisioning tokens. -* **claims** (optional): an object with custom options for each provisioner. - Options supported are: - * **minTLSCertDuration**: minimum duration of a certificate, set to 5m by - default. - * **maxTLSCertDuration**: maximum duration of a certificate, set to 24h by - default. - * **defaultTLSCertDuration**: default duration of the certificate, set to 24h - by default. - * **disableRenewal**: whether or not to disable certificate renewal, set to false - by default. - -## EXAMPLES - -Add a single provisioner: -''' -$ step beta ca provisioner add max@smallstep.com --type JWK --create -''' - -Remove a provisioner: -''' -$ step beta ca provisioner remove max@smallstep.com -'''`, - } -} - -func parseIntaceAge(ctx *cli.Context) (age string, err error) { - if !ctx.IsSet("instance-age") { - return - } - age = ctx.String("instance-age") - dur, err := time.ParseDuration(age) - if err != nil { - return "", err - } - if dur < 0 { - return "", errs.MinSizeFlag(ctx, "instance-age", "0s") - } - return -} - -func removeElements(list, rems []string) []string { - if len(list) == 0 { - return list - } - for _, rem := range rems { - for i, elem := range list { - if elem == rem { - list[i] = list[len(list)-1] - list = list[:len(list)-1] - break - } - } - } - return list -} - -var ( - x509TemplateFlag = cli.StringFlag{ - Name: "x509-template", - Usage: `The x509 certificate template , a JSON representation of the certificate to create.`, - } - x509TemplateDataFlag = cli.StringFlag{ - Name: "x509-template-data", - Usage: `The x509 certificate template data , a JSON map of data that can be used by the certificate template.`, - } - sshTemplateFlag = cli.StringFlag{ - Name: "ssh-template", - Usage: `The x509 certificate template , a JSON representation of the certificate to create.`, - } - sshTemplateDataFlag = cli.StringFlag{ - Name: "ssh-template-data", - Usage: `The ssh certificate template data , a JSON map of data that can be used by the certificate template.`, - } - x509MinDurFlag = cli.StringFlag{ - Name: "x509-min-dur", - Usage: `The minimum for an x509 certificate generated by this provisioner.`, - } - x509MaxDurFlag = cli.StringFlag{ - Name: "x509-max-dur", - Usage: `The maximum for an x509 certificate generated by this provisioner.`, - } - x509DefaultDurFlag = cli.StringFlag{ - Name: "x509-default-dur", - Usage: `The default for an x509 certificate generated by this provisioner.`, - } - sshUserMinDurFlag = cli.StringFlag{ - Name: "ssh-user-min-dur", - Usage: `The minimum for an ssh user certificate generated by this provisioner.`, - } - sshUserMaxDurFlag = cli.StringFlag{ - Name: "ssh-user-max-dur", - Usage: `The maximum for an ssh user certificate generated by this provisioner.`, - } - sshUserDefaultDurFlag = cli.StringFlag{ - Name: "ssh-user-default-dur", - Usage: `The maximum for an ssh user certificate generated by this provisioner.`, - } - sshHostMinDurFlag = cli.StringFlag{ - Name: "ssh-host-min-dur", - Usage: `The minimum for an ssh host certificate generated by this provisioner.`, - } - sshHostMaxDurFlag = cli.StringFlag{ - Name: "ssh-host-max-dur", - Usage: `The maximum for an ssh host certificate generated by this provisioner.`, - } - sshHostDefaultDurFlag = cli.StringFlag{ - Name: "ssh-host-default-dur", - Usage: `The maximum for an ssh host certificate generated by this provisioner.`, - } - disableRenewalFlag = cli.BoolFlag{ - Name: "disable-renewal", - Usage: `Disable renewal for all certificates generated by this provisioner`, - } - enableX509Flag = cli.BoolFlag{ - Name: "x509", - Usage: `Enable provisioning of x509 certificates.`, - } - enableSSHFlag = cli.BoolFlag{ - Name: "ssh", - Usage: `Enable provisioning of ssh certificates.`, - } - forceCNFlag = cli.BoolFlag{ - Name: "force-cn", - Usage: `Always set the common name in provisioned certificates.`, - } - - // Cloud provisioner flags - awsAccountFlag = cli.StringSliceFlag{ - Name: "aws-account", - Usage: `The AWS account used to validate the identity documents. -Use the flag multiple times to configure multiple accounts.`, - } - removeAWSAccountFlag = cli.StringSliceFlag{ - Name: "remove-aws-account", - Usage: `Remove an AWS account used to validate the identity documents. -Use the flag multiple times to remove multiple accounts.`, - } - azureTenantFlag = cli.StringFlag{ - Name: "azure-tenant", - Usage: `The Microsoft Azure tenant used to validate the identity tokens.`, - } - azureResourceGroupFlag = cli.StringSliceFlag{ - Name: "azure-resource-group", - Usage: `The Microsoft Azure resource group used to validate the identity tokens. -Use the flag multipl etimes to configure multiple resource groups`, - } - removeAzureResourceGroupFlag = cli.StringSliceFlag{ - Name: "remove-azure-resource-group", - Usage: `Remove a Microsoft Azure resource group used to validate the identity tokens. -Use the flag multipl etimes to configure multiple resource groups`, - } - gcpServiceAccountFlag = cli.StringSliceFlag{ - Name: "gcp-service-account", - Usage: `The Google service account or used to validate the identity tokens. -Use the flag multiple times to configure multiple service accounts.`, - } - removeGCPServiceAccountFlag = cli.StringSliceFlag{ - Name: "remove-gcp-service-account", - Usage: `Remove a Google service account or used to validate the identity tokens. -Use the flag multiple times to configure multiple service accounts.`, - } - gcpProjectFlag = cli.StringSliceFlag{ - Name: "gcp-project", - Usage: `The Google project used to validate the identity tokens. -Use the flag multipl etimes to configure multiple projects`, - } - removeGCPProjectFlag = cli.StringSliceFlag{ - Name: "remove-gcp-project", - Usage: `Remove a Google project used to validate the identity tokens. -Use the flag multipl etimes to configure multiple projects`, - } - instanceAgeFlag = cli.DurationFlag{ - Name: "instance-age", - Usage: `The maximum to grant a certificate in AWS and GCP provisioners. -A is sequence of decimal numbers, each with optional fraction and a -unit suffix, such as "300ms", "-1.5h" or "2h45m". Valid time units are "ns", -"us" (or "μs"), "ms", "s", "m", "h".`, - } - iidRootsFlag = cli.StringFlag{ - Name: "iid-roots", - Usage: `The containing the certificates used to validate the -instance identity documents in AWS.`, - } - disableCustomSANsFlag = cli.BoolFlag{ - Name: "disable-custom-sans", - Usage: `On cloud provisioners, if enabled only the internal DNS and IP will be added as a SAN. -By default it will accept any SAN in the CSR.`, - } - disableTOFUFlag = cli.BoolFlag{ - Name: "disable-trust-on-first-use,disable-tofu", - Usage: `On cloud provisioners, if enabled multiple sign request for this provisioner -with the same instance will be accepted. By default only the first request -will be accepted.`, - } -) diff --git a/command/ca/provisionerbeta/remove.go b/command/ca/provisionerbeta/remove.go deleted file mode 100644 index 9787b310..00000000 --- a/command/ca/provisionerbeta/remove.go +++ /dev/null @@ -1,56 +0,0 @@ -package provisionerbeta - -import ( - "github.com/smallstep/certificates/ca" - "github.com/smallstep/cli/flags" - "github.com/smallstep/cli/utils/cautils" - "github.com/urfave/cli" - "go.step.sm/cli-utils/errs" -) - -func removeCommand() cli.Command { - return cli.Command{ - Name: "remove", - Action: cli.ActionFunc(removeAction), - Usage: "remove a provisioner from the CA configuration", - UsageText: `**step beta ca provisioner remove** -[**--admin-cert**=] [**--admin-key**=] [**--admin-provisioner**=] -[**--admin-subject**=] [**--password-file**=] [**--ca-url**=] -[**--root**=] [**--context**=]`, - Flags: []cli.Flag{ - flags.AdminCert, - flags.AdminKey, - flags.AdminProvisioner, - flags.AdminSubject, - flags.PasswordFile, - flags.CaURL, - flags.Root, - flags.Context, - }, - Description: `**step beta ca provisioner remove** removes a provisioner from the CA configuration. - -## EXAMPLES - -Remove provisioner by name: -''' -$ step beta ca provisioner remove acme -''' -`, - } -} - -func removeAction(ctx *cli.Context) (err error) { - if err := errs.NumberOfArguments(ctx, 1); err != nil { - return err - } - - args := ctx.Args() - name := args.Get(0) - - client, err := cautils.NewAdminClient(ctx) - if err != nil { - return err - } - - return client.RemoveProvisioner(ca.WithProvisionerName(name)) -} diff --git a/command/ca/rekey.go b/command/ca/rekey.go index 35b07fa7..aa80dcac 100644 --- a/command/ca/rekey.go +++ b/command/ca/rekey.go @@ -9,17 +9,19 @@ import ( "syscall" "time" - "github.com/smallstep/cli/crypto/keys" - "github.com/smallstep/cli/crypto/pemutil" - "github.com/smallstep/cli/utils" - "github.com/pkg/errors" + "github.com/urfave/cli" + "github.com/smallstep/certificates/pki" + "github.com/smallstep/cli-utils/command" + "github.com/smallstep/cli-utils/errs" + "github.com/smallstep/cli-utils/ui" + "go.step.sm/crypto/keyutil" + "go.step.sm/crypto/pemutil" + "github.com/smallstep/cli/flags" - "github.com/smallstep/cli/ui" - "github.com/urfave/cli" - "go.step.sm/cli-utils/command" - "go.step.sm/cli-utils/errs" + "github.com/smallstep/cli/internal/cryptoutil" + "github.com/smallstep/cli/utils" ) func rekeyCertificateCommand() cli.Command { @@ -31,7 +33,7 @@ func rekeyCertificateCommand() cli.Command { [**--out-cert**=] [**--out-key**=] [**--private-key**=] [**--ca-url**=] [**--root**=] [**--password-file**=] [**--expires-in**=] [**--force**] [**--exec**=] [**--daemon**] -[**--kty**=] [**--curve**=] [**--size**=] +[**--kms**=] [**--kty**=] [**--curve**=] [**--size**=] [**--expires-in**=] [**--pid**=] [**--pid-file**=] [**--signal**=] [**--exec**=] [**--rekey-period**=]`, Description: ` @@ -51,6 +53,10 @@ fixed period can be set with the **--rekey-period** flag. The **--daemon** flag can be combined with **--pid**, **--signal**, or **--exec** to provide certificate reloads on your services. +The **--kms** flag rekeys an existing key in a KMS with another key from the same +KMS. It does not support generating new keys, using the **--daemon** flag, or +rekeying across different KMS instances. + ## POSITIONAL ARGUMENTS @@ -77,6 +83,24 @@ Rekey a certificate forcing the overwrite of the previous certificate and key $ step ca rekey --force internal.crt internal.key ''' +Rekey a certificate using a KMS, with another from the same KMS: +''' +$ step ca rekey --private-key 'yubikey:slot-id=9a?pin-value=123456' \ + yubikey.crt 'yubikey:slot-id=82?pin-value=123456' +''' + +Rekey a certificate using a KMS with the <--kms> flag: +''' +$ step ca rekey \ + --kms 'pkcs11:module-path=/usr/local/lib/softhsm/libsofthsm2.so;token=smallstep?pin-value=password' \ + --private-key 'pkcs11:id=4002' pkcs11.crt 'pkcs11:id=4001' +''' + +''' +$ step ca rekey --key yubikey:pin-value=123456 --private-key yubikey:slot-id=9a \ + yubikey.crt 'yubikey:slot-id=82 +''' + Rekey a certificate providing the <--ca-url> and <--root> flags: ''' $ step ca rekey --ca-url https://ca.smallstep.com:9000 \ @@ -166,7 +190,7 @@ flag.`, cli.StringFlag{ Name: "pid-file", Usage: `The from which to read the process id that will be signaled after the certificate -has been rekeyed. By default the the SIGHUP (1) signal will be used, but this can be configured with the **--signal** +has been rekeyed. By default the SIGHUP (1) signal will be used, but this can be configured with the **--signal** flag.`, }, cli.IntFlag{ @@ -193,6 +217,7 @@ Requires the **--daemon** flag. The is a sequence of decimal numbers, each with optional fraction and a unit suffix, such as "300ms", "1.5h", or "2h45m". Valid time units are "ns", "us" (or "μs"), "ms", "s", "m", "h".`, }, + flags.KMSUri, flags.KTY, flags.Curve, flags.Size, @@ -219,6 +244,21 @@ func rekeyCertificateAction(ctx *cli.Context) error { isDaemon := ctx.Bool("daemon") execCmd := ctx.String("exec") givenPrivate := ctx.String("private-key") + kmsURI := ctx.String("kms") + + // For now, if the --kms flag is given, do not allow to generate a new key + // and write it on disk. We can't use the daemon mode because we + // cannot generate new keys. + if kmsURI != "" || cryptoutil.IsKMS(keyFile) { + switch { + case givenPrivate == "": + return errs.RequiredWithFlag(ctx, "kms", "private-key") + case ctx.IsSet("out-key"): + return errs.IncompatibleFlagWithFlag(ctx, "kms", "out-key") + case isDaemon: + return errs.IncompatibleFlagWithFlag(ctx, "kms", "daemon") + } + } outCert := ctx.String("out-cert") if outCert == "" { @@ -240,12 +280,12 @@ func rekeyCertificateAction(ctx *cli.Context) error { } var expiresIn, rekeyPeriod time.Duration - if s := ctx.String("expires-in"); len(s)> 0 { + if s := ctx.String("expires-in"); s != "" { if expiresIn, err = time.ParseDuration(s); err != nil { return errs.InvalidFlagValue(ctx, "expires-in", s, "") } } - if s := ctx.String("rekey-period"); len(s)> 0 { + if s := ctx.String("rekey-period"); s != "" { if rekeyPeriod, err = time.ParseDuration(s); err != nil { return errs.InvalidFlagValue(ctx, "rekey-period", s, "") } @@ -266,7 +306,7 @@ func rekeyCertificateAction(ctx *cli.Context) error { } pidFile := ctx.String("pid-file") - if len(pidFile)> 0 { + if pidFile != "" { pidB, err := os.ReadFile(pidFile) if err != nil { return errs.FileError(err, pidFile) @@ -285,7 +325,7 @@ func rekeyCertificateAction(ctx *cli.Context) error { return errs.InvalidFlagValue(ctx, "signal", strconv.Itoa(signum), "") } - cert, err := tlsLoadX509KeyPair(certFile, keyFile, passFile) + cert, err := tlsLoadX509KeyPair(kmsURI, certFile, keyFile, passFile) if err != nil { return err } @@ -315,6 +355,7 @@ func rekeyCertificateAction(ctx *cli.Context) error { // Do not rekey if (cert.notAfter - now)> (expiresIn + jitter) if expiresIn> 0 { + //nolint:gosec // The random number below is not being used for crypto. jitter := rand.Int63n(int64(expiresIn / 20)) if d := time.Until(leaf.NotAfter); d> expiresIn+time.Duration(jitter) { ui.Printf("certificate not rekeyed: expires in %s\n", d.Round(time.Second)) @@ -322,23 +363,27 @@ func rekeyCertificateAction(ctx *cli.Context) error { } } - var priv crypto.PrivateKey + var signer crypto.Signer if givenPrivate == "" { kty, crv, size, err := utils.GetKeyDetailsFromCLI(ctx, false, "kty", "curve", "size") if err != nil { return err } - priv, err = keys.GenerateKey(kty, crv, size) + signer, err = keyutil.GenerateSigner(kty, crv, size) if err != nil { return err } } else { - priv, err = pemutil.Read(givenPrivate) + opts := []pemutil.Options{pemutil.WithFilename(givenPrivate)} + if passFile != "" { + opts = append(opts, pemutil.WithPasswordFile(passFile)) + } + signer, err = cryptoutil.CreateSigner(kmsURI, givenPrivate, opts...) if err != nil { return err } } - if _, err := renewer.Rekey(priv, outCert, outKey, ctx.IsSet("out-key") || givenPrivate == ""); err != nil { + if _, err := renewer.Rekey(signer, outCert, outKey, ctx.IsSet("out-key") || givenPrivate == ""); err != nil { return err } diff --git a/command/ca/renew.go b/command/ca/renew.go index 1c612580..b26b6be8 100644 --- a/command/ca/renew.go +++ b/command/ca/renew.go @@ -5,10 +5,12 @@ import ( cryptoRand "crypto/rand" "crypto/tls" "crypto/x509" + "encoding/base64" "encoding/pem" "log" "math/rand" "net/http" + "net/url" "os" "os/exec" "os/signal" @@ -18,19 +20,24 @@ import ( "time" "github.com/pkg/errors" + "github.com/urfave/cli" + "github.com/smallstep/certificates/api" "github.com/smallstep/certificates/ca" "github.com/smallstep/certificates/pki" - "github.com/smallstep/cli/crypto/pemutil" - "github.com/smallstep/cli/crypto/x509util" + "github.com/smallstep/cli-utils/command" + "github.com/smallstep/cli-utils/errs" + "github.com/smallstep/cli-utils/fileutil" + "github.com/smallstep/cli-utils/ui" + "go.step.sm/crypto/jose" + "go.step.sm/crypto/pemutil" + "go.step.sm/crypto/x509util" + "github.com/smallstep/cli/flags" - "github.com/smallstep/cli/ui" - "github.com/smallstep/cli/utils" + "github.com/smallstep/cli/internal/cryptoutil" + "github.com/smallstep/cli/token" "github.com/smallstep/cli/utils/cautils" "github.com/smallstep/cli/utils/sysutils" - "github.com/urfave/cli" - "go.step.sm/cli-utils/command" - "go.step.sm/cli-utils/errs" ) func renewCertificateCommand() cli.Command { @@ -39,10 +46,10 @@ func renewCertificateCommand() cli.Command { Action: command.ActionFunc(renewCertificateAction), Usage: "renew a certificate", UsageText: `**step ca renew** -[**--password-file**=] [**--out**=] [**--expires-in**=] -[**--force**] [**--expires-in**=] [**--pid**=] -[**--pid-file**=] [**--signal**=] [**--exec**=] [**--daemon**] -[**--renew-period**=] [**--ca-url**=] [**--root**=] [**--context**=]`, +[**--mtls**] [**--password-file**=] [**--out**=] [**--expires-in**=] +[**--force**] [**--pid**=] [**--pid-file**=] [**--signal**=] +[**--exec**=] [**--daemon**] [**--renew-period**=] [**--kms**=] +[**--ca-url**=] [**--root**=] [**--context**=]`, Description: ` **step ca renew** command renews the given certificate (with a request to the certificate authority) and writes the new certificate to disk - either overwriting @@ -58,6 +65,15 @@ fixed period can be set with the **--renew-period** flag. The **--daemon** flag can be combined with **--pid**, **--signal**, or **--exec** to provide certificate reloads on your services. +By default, the renew command authenticates to step-ca using mTLS, except when +the certificate is expired and renewal after expiry is allowed by the CA. + +There are scenarios where mTLS is not possible: When step-ca is behind a layer 7 proxy, +when the server's leaf certificate EKU is not configured for client authentication, +or when the server is a StepCAS RA for an upstream step-ca server. +For these scenarios, use **--mtls=false** to force a flow that uses X5C +token-based authentication. + ## POSITIONAL ARGUMENTS @@ -84,6 +100,23 @@ Renew a certificate forcing the overwrite of the previous certificate: $ step ca renew --force internal.crt internal.key ''' +Renew a certificate using the token flow instead of mTLS: +''' +$ step ca renew --mtls=false --force internal.crt internal.key +''' + +Renew a certificate which key is in a KMS: +''' +$ step ca renew yubikey.crt 'yubikey:slot-id=9a?pin-value=123456' +''' + +Renew a certificate which key is in a KMS, using the <--kms> flag: +''' +$ step ca renew \ + --kms 'pkcs11:module-path=/usr/local/lib/softhsm/libsofthsm2.so;token=smallstep?pin-value=password' \ + pkcs11.crt 'pkcs11:id=4001' +''' + Renew a certificate providing the <--ca-url> and <--root> flags: ''' $ step ca renew --ca-url https://ca.smallstep.com:9000 \ @@ -130,10 +163,16 @@ files, certificates, and keys created with **step ca init**: $ step ca renew --offline internal.crt internal.key '''`, Flags: []cli.Flag{ + cli.BoolTFlag{ + Name: "mtls", + Usage: `Use mTLS to renew a certificate. Use --mtls=false to force the token +authorization flow instead.`, + }, flags.CaConfig, flags.Force, flags.Offline, flags.PasswordFile, + flags.KMSUri, cli.StringFlag{ Name: "out,output-file", Usage: "The new certificate path. Defaults to overwriting the positional argument", @@ -157,7 +196,7 @@ flag.`, cli.StringFlag{ Name: "pid-file", Usage: `The from which to read the process id that will be signaled after the certificate -has been renewed. By default the the SIGHUP (1) signal will be used, but this can be configured with the **--signal** +has been renewed. By default the SIGHUP (1) signal will be used, but this can be configured with the **--signal** flag.`, }, cli.IntFlag{ @@ -203,6 +242,7 @@ func renewCertificateAction(ctx *cli.Context) error { passFile := ctx.String("password-file") isDaemon := ctx.Bool("daemon") execCmd := ctx.String("exec") + kmsURI := ctx.String("kms") outFile := ctx.String("out") if outFile == "" { @@ -220,12 +260,12 @@ func renewCertificateAction(ctx *cli.Context) error { } var expiresIn, renewPeriod time.Duration - if s := ctx.String("expires-in"); len(s)> 0 { + if s := ctx.String("expires-in"); s != "" { if expiresIn, err = time.ParseDuration(s); err != nil { return errs.InvalidFlagValue(ctx, "expires-in", s, "") } } - if s := ctx.String("renew-period"); len(s)> 0 { + if s := ctx.String("renew-period"); s != "" { if renewPeriod, err = time.ParseDuration(s); err != nil { return errs.InvalidFlagValue(ctx, "renew-period", s, "") } @@ -246,7 +286,7 @@ func renewCertificateAction(ctx *cli.Context) error { } pidFile := ctx.String("pid-file") - if len(pidFile)> 0 { + if pidFile != "" { pidB, err := os.ReadFile(pidFile) if err != nil { return errs.FileError(err, pidFile) @@ -265,16 +305,12 @@ func renewCertificateAction(ctx *cli.Context) error { return errs.InvalidFlagValue(ctx, "signal", strconv.Itoa(signum), "") } - cert, err := tlsLoadX509KeyPair(certFile, keyFile, passFile) + cert, err := tlsLoadX509KeyPair(kmsURI, certFile, keyFile, passFile) if err != nil { return err } - leaf := cert.Leaf - if leaf.NotAfter.Before(time.Now()) { - return errors.New("cannot renew an expired certificate") - } - cvp := leaf.NotAfter.Sub(leaf.NotBefore) + cvp := cert.Leaf.NotAfter.Sub(cert.Leaf.NotBefore) if renewPeriod> 0 && renewPeriod>= cvp { return errors.Errorf("flag '--renew-period' must be within (lower than) the certificate "+ "validity period; renew-period=%v, cert-validity-period=%v", renewPeriod, cvp) @@ -293,14 +329,15 @@ func renewCertificateAction(ctx *cli.Context) error { if isDaemon { // Force is always enabled when daemon mode is used ctx.Set("force", "true") - next := nextRenewDuration(leaf, expiresIn, renewPeriod) + next := nextRenewDuration(cert.Leaf, expiresIn, renewPeriod) return renewer.Daemon(outFile, next, expiresIn, renewPeriod, afterRenew) } // Do not renew if (cert.notAfter - now)> (expiresIn + jitter) if expiresIn> 0 { + //nolint:gosec // The random number below is not being used for crypto. jitter := rand.Int63n(int64(expiresIn / 20)) - if d := time.Until(leaf.NotAfter); d> expiresIn+time.Duration(jitter) { + if d := time.Until(cert.Leaf.NotAfter); d> expiresIn+time.Duration(jitter) { ui.Printf("certificate not renewed: expires in %s\n", d.Round(time.Second)) return nil } @@ -332,8 +369,10 @@ func nextRenewDuration(leaf *x509.Certificate, expiresIn, renewPeriod time.Durat case d <= 0: return 0 case d < period/20: + //nolint:gosec // The random number below is not being used for crypto. return time.Duration(rand.Int63n(int64(d))) default: + //nolint:gosec // The random number below is not being used for crypto. n := rand.Int63n(int64(period / 20)) d -= time.Duration(n) return d @@ -365,6 +404,7 @@ func runExecCmd(execCmd string) error { return nil } parts := strings.Split(execCmd, " ") + //nolint:gosec // arguments controlled by step. cmd := exec.Command(parts[0], parts[1:]...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout @@ -377,6 +417,9 @@ type renewer struct { transport *http.Transport key crypto.PrivateKey offline bool + cert tls.Certificate + caURL *url.URL + mtls bool } func newRenewer(ctx *cli.Context, caURL string, cert tls.Certificate, rootFile string) (*renewer, error) { @@ -392,12 +435,16 @@ func newRenewer(ctx *cli.Context, caURL string, cert tls.Certificate, rootFile s tr := &http.Transport{ Proxy: http.ProxyFromEnvironment, TLSClientConfig: &tls.Config{ - Certificates: []tls.Certificate{cert}, RootCAs: rootCAs, PreferServerCipherSuites: true, + MinVersion: tls.VersionTLS12, }, } + if time.Now().Before(cert.Leaf.NotAfter) { + tr.TLSClientConfig.Certificates = []tls.Certificate{cert} + } + var client cautils.CaClient offline := ctx.Bool("offline") if offline { @@ -416,21 +463,33 @@ func newRenewer(ctx *cli.Context, caURL string, cert tls.Certificate, rootFile s } } + u, err := url.Parse(client.GetCaURL()) + if err != nil { + return nil, errors.Errorf("error parsing CA URL: %s", client.GetCaURL()) + } + return &renewer{ client: client, transport: tr, key: cert.PrivateKey, offline: offline, + cert: cert, + caURL: u, + mtls: ctx.Bool("mtls"), }, nil } -func (r *renewer) Renew(outFile string) (*api.SignResponse, error) { - resp, err := r.client.Renew(r.transport) +func (r *renewer) Renew(outFile string) (resp *api.SignResponse, err error) { + if !r.mtls || time.Now().After(r.cert.Leaf.NotAfter) { + resp, err = r.RenewWithToken(r.cert) + } else { + resp, err = r.client.Renew(r.transport) + } if err != nil { return nil, errors.Wrap(err, "error renewing certificate") } - if resp.CertChainPEM == nil || len(resp.CertChainPEM) == 0 { + if len(resp.CertChainPEM) == 0 { resp.CertChainPEM = []api.Certificate{resp.ServerPEM, resp.CaPEM} } var data []byte @@ -441,7 +500,7 @@ func (r *renewer) Renew(outFile string) (*api.SignResponse, error) { } data = append(data, pem.EncodeToMemory(pemblk)...) } - if err := utils.WriteFile(outFile, data, 0600); err != nil { + if err := fileutil.WriteFile(outFile, data, 0o600); err != nil { return nil, errs.FileError(err, outFile) } @@ -461,7 +520,7 @@ func (r *renewer) Rekey(priv interface{}, outCert, outKey string, writePrivateKe if err != nil { return nil, errors.Wrap(err, "error rekeying certificate") } - if resp.CertChainPEM == nil || len(resp.CertChainPEM) == 0 { + if len(resp.CertChainPEM) == 0 { resp.CertChainPEM = []api.Certificate{resp.ServerPEM, resp.CaPEM} } var data []byte @@ -472,11 +531,11 @@ func (r *renewer) Rekey(priv interface{}, outCert, outKey string, writePrivateKe } data = append(data, pem.EncodeToMemory(pemblk)...) } - if err := utils.WriteFile(outCert, data, 0600); err != nil { + if err := fileutil.WriteFile(outCert, data, 0o600); err != nil { return nil, errs.FileError(err, outCert) } if writePrivateKey { - _, err = pemutil.Serialize(priv, pemutil.ToFile(outKey, 0600)) + _, err = pemutil.Serialize(priv, pemutil.ToFile(outKey, 0o600)) if err != nil { return nil, err } @@ -489,7 +548,7 @@ func (r *renewer) Rekey(priv interface{}, outCert, outKey string, writePrivateKe // NOTE: this function logs each time the certificate is successfully renewed. func (r *renewer) RenewAndPrepareNext(outFile string, expiresIn, renewPeriod time.Duration) (time.Duration, error) { const durationOnErrors = 1 * time.Minute - Info := log.New(os.Stdout, "INFO: ", log.LstdFlags) + infoLog := log.New(os.Stdout, "INFO: ", log.LstdFlags) resp, err := r.Renew(outFile) if err != nil { @@ -515,25 +574,26 @@ func (r *renewer) RenewAndPrepareNext(outFile string, expiresIn, renewPeriod tim } // Prepare next transport + r.cert = cert r.transport.TLSClientConfig.Certificates = []tls.Certificate{cert} // Get next renew duration next := nextRenewDuration(resp.ServerPEM.Certificate, expiresIn, renewPeriod) - Info.Printf("%s certificate renewed, next in %s", resp.ServerPEM.Certificate.Subject.CommonName, next.Round(time.Second)) + infoLog.Printf("%s certificate renewed, next in %s", resp.ServerPEM.Certificate.Subject.CommonName, next.Round(time.Second)) return next, nil } func (r *renewer) Daemon(outFile string, next, expiresIn, renewPeriod time.Duration, afterRenew func() error) error { // Loggers - Info := log.New(os.Stdout, "INFO: ", log.LstdFlags) - Error := log.New(os.Stderr, "ERROR: ", log.LstdFlags) + infoLog := log.New(os.Stdout, "INFO: ", log.LstdFlags) + errLog := log.New(os.Stderr, "ERROR: ", log.LstdFlags) // Daemon loop signals := make(chan os.Signal, 1) signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) defer signal.Stop(signals) - Info.Printf("first renewal in %s", next.Round(time.Second)) + infoLog.Printf("first renewal in %s", next.Round(time.Second)) var err error for { select { @@ -541,24 +601,59 @@ func (r *renewer) Daemon(outFile string, next, expiresIn, renewPeriod time.Durat switch sig { case syscall.SIGHUP: if next, err = r.RenewAndPrepareNext(outFile, expiresIn, renewPeriod); err != nil { - Error.Println(err) + errLog.Println(err) } else if err := afterRenew(); err != nil { - Error.Println(err) + errLog.Println(err) } case syscall.SIGINT, syscall.SIGTERM: return nil } case <-time.after(next): if next, err = r.RenewAndPrepareNext(outFile, expiresIn, renewPeriod); err != nil { - Error.Println(err) + errLog.Println(err) } else if err := afterRenew(); err != nil { - Error.Println(err) + errLog.Println(err) } } } } -func tlsLoadX509KeyPair(certFile, keyFile, passFile string) (tls.Certificate, error) { +// RenewWithToken creates an authorization token with the given certificate and +// attempts to renew the given certificate. It can be used to renew expired +// certificates. +func (r *renewer) RenewWithToken(cert tls.Certificate) (*api.SignResponse, error) { + claims, err := token.NewClaims( + token.WithAudience(r.caURL.ResolveReference(&url.URL{Path: "/renew"}).String()), + token.WithIssuer("step-ca-client/1.0"), + token.WithSubject(cert.Leaf.Subject.CommonName), + ) + if err != nil { + return nil, errors.Wrap(err, "error creating authorization token") + } + var x5c []string + for _, b := range cert.Certificate { + x5c = append(x5c, base64.StdEncoding.EncodeToString(b)) + } + if claims.ExtraHeaders == nil { + claims.ExtraHeaders = make(map[string]interface{}) + } + claims.ExtraHeaders[jose.X5cInsecureKey] = x5c + + tok, err := claims.Sign("", cert.PrivateKey) + if err != nil { + return nil, errors.Wrap(err, "error signing authorization token") + } + + // Remove existing certificate from the transport. And close keep-alive + // connections. When daemon is used we don't want to re-use the connection + // that did not include a certificate. + r.transport.TLSClientConfig.Certificates = nil + defer r.transport.CloseIdleConnections() + + return r.client.RenewWithToken(tok) +} + +func tlsLoadX509KeyPair(kms, certFile, keyFile, passFile string) (tls.Certificate, error) { x509Chain, err := pemutil.ReadCertificateBundle(certFile) if err != nil { return tls.Certificate{}, errs.Wrap(err, "error reading certificate chain") @@ -572,14 +667,13 @@ func tlsLoadX509KeyPair(certFile, keyFile, passFile string) (tls.Certificate, er if passFile != "" { opts = append(opts, pemutil.WithPasswordFile(passFile)) } - pk, err := pemutil.Read(keyFile, opts...) + signer, err := cryptoutil.CreateSigner(kms, keyFile, opts...) if err != nil { - return tls.Certificate{}, errs.Wrap(err, "error parsing private key") + return tls.Certificate{}, errs.Wrap(err, "error loading private key") } - return tls.Certificate{ Certificate: x509ChainBytes, - PrivateKey: pk, + PrivateKey: signer, Leaf: x509Chain[0], }, nil } diff --git a/command/ca/revoke.go b/command/ca/revoke.go index 21aafbbb..2d5415ee 100644 --- a/command/ca/revoke.go +++ b/command/ca/revoke.go @@ -4,6 +4,8 @@ import ( "crypto/tls" "crypto/x509" "encoding/pem" + "fmt" + "math/big" "net/http" "os" "strconv" @@ -11,20 +13,22 @@ import ( "time" "github.com/pkg/errors" + "github.com/urfave/cli" + "github.com/smallstep/certificates/api" "github.com/smallstep/certificates/authority/provisioner" "github.com/smallstep/certificates/ca" "github.com/smallstep/certificates/pki" - "github.com/smallstep/cli/crypto/pemutil" - "github.com/smallstep/cli/crypto/x509util" + "github.com/smallstep/cli-utils/command" + "github.com/smallstep/cli-utils/errs" + "github.com/smallstep/cli-utils/ui" + "go.step.sm/crypto/jose" + "go.step.sm/crypto/pemutil" + "go.step.sm/crypto/x509util" + "golang.org/x/crypto/ocsp" + "github.com/smallstep/cli/flags" - "github.com/smallstep/cli/jose" - "github.com/smallstep/cli/ui" "github.com/smallstep/cli/utils/cautils" - "github.com/urfave/cli" - "go.step.sm/cli-utils/command" - "go.step.sm/cli-utils/errs" - "golang.org/x/crypto/ocsp" ) /* @@ -41,7 +45,7 @@ func revokeCertificateCommand() cli.Command { Usage: "revoke a certificate", UsageText: `**step ca revoke** [**--cert**=] [**--key**=] [**--token**=] -[**--reason**=] [**--reasonCode**=] [**-offline**] +[**--reason**=] [**--reasonCode**=] [**--offline**] [**--ca-url**=] [**--root**=] [**--context**=]`, Description: ` **step ca revoke** command revokes a certificate with the given serial @@ -62,19 +66,29 @@ can verify certificates in a simple, decentralized manner without relying on centralized 3rd parties. Passive revocation works best with short certificate lifetimes. -**step ca revoke** currently only supports passive revocation. Active revocation -is on our roadmap. +A revocation request can be authorized using a JWK provisioner token, or using a +client certificate. + +When you supply a serial number, you're prompted to choose a provisioner, +and a provisioner token is transparently generated. Any JWK provisioner +can revoke any certificate. + +When you supply a certificate and private key (with --crt and --key), +mTLS is used to authorize the revocation. + +Certificates generated using an OIDC provisioner cannot be revoked +by their serial number. ## POSITIONAL ARGUMENTS -: The serial number of the certificate that should be revoked. Can be left blank -either to be supplied by prompt or when using --cert and --key flags for +: The serial number of the certificate that should be revoked. Can be left blank, +either to be supplied by prompt, or when using the --cert and --key flags for revocation over mTLS. ## EXAMPLES -Revoke a certificate using a transparently generated API token and the default +Revoke a certificate using a transparently generated JWK provisioner token and the default 'unspecified' reason: ''' $ step ca revoke 308893286343609293989051180431574390766 @@ -98,7 +112,7 @@ request (rather than a token) over mTLS: $ step ca revoke --cert mike.cert --key mike.key ''' -Revoke a certificate using a token, generated by a provisioner, to authorize +Revoke a certificate using a JWK token, pre-generated by a provisioner, to authorize the request with the CA: ''' $ TOKEN=$(step ca token --revoke 308893286343609293989051180431574390766) @@ -111,7 +125,7 @@ $ step ca revoke --offline 308893286343609293989051180431574390766 ''' Revoke a certificate in offline mode using --cert and --key (the cert/key pair -will be validated against the root and intermediate certifcates configured in +will be validated against the root and intermediate certificates configured in the step CA): ''' $ step ca revoke --offline --cert foo.crt --key foo.key @@ -219,7 +233,7 @@ func revokeCertificateAction(ctx *cli.Context) error { // If cert and key are passed then infer the serial number and certificate // that should be revoked. - if len(certFile)> 0 || len(keyFile)> 0 { + if certFile != "" || keyFile != "" { // Must be using cert/key flags for mTLS revoke so should be 0 cmd line args. if ctx.NArg()> 0 { return errors.Errorf("'%s %s --cert --key ' expects no additional positional arguments", ctx.App.Name, ctx.Command.Name) @@ -230,10 +244,10 @@ func revokeCertificateAction(ctx *cli.Context) error { if keyFile == "" { return errs.RequiredWithFlag(ctx, "cert", "key") } - if len(token)> 0 { + if token != "" { errs.IncompatibleFlagWithFlag(ctx, "cert", "token") } - if len(serial)> 0 { + if serial != "" { errs.IncompatibleFlagWithFlag(ctx, "cert", "serial") } var cert []*x509.Certificate @@ -247,6 +261,12 @@ func revokeCertificateAction(ctx *cli.Context) error { if err := errs.NumberOfArguments(ctx, 1); err != nil { return err } + + sn, ok := new(big.Int).SetString(serial, 0) + if !ok { + return fmt.Errorf("'%s' is not a valid serial number - use a base 10 representation or add a prefix indicating the base", serial) + } + serial = sn.String() if token == "" { // No token and no cert/key pair - so generate a token. token, err = flow.GenerateToken(ctx, &serial) @@ -288,7 +308,7 @@ func newRevokeFlow(ctx *cli.Context, certFile, keyFile string) (*revokeFlow, err if err != nil { return nil, err } - if len(certFile)> 0 || len(keyFile)> 0 { + if certFile != "" || keyFile != "" { if err := offlineClient.VerifyClientCert(certFile, keyFile); err != nil { return nil, err } @@ -314,7 +334,7 @@ func (f *revokeFlow) getClient(ctx *cli.Context, serial, token string) (cautils. rootFile := ctx.String("root") var options []ca.ClientOption - if len(token)> 0 { + if token != "" { tok, err := jose.ParseSigned(token) if err != nil { return nil, errors.Wrap(err, "error parsing flag '--token'") @@ -328,7 +348,7 @@ func (f *revokeFlow) getClient(ctx *cli.Context, serial, token string) (cautils. } // Prepare client for bootstrap or provisioning tokens - if len(claims.SHA)> 0 && len(claims.Audience)> 0 && strings.HasPrefix(strings.ToLower(claims.Audience[0]), "http") { + if claims.SHA != "" && len(claims.Audience)> 0 && strings.HasPrefix(strings.ToLower(claims.Audience[0]), "http") { if caURL == "" { caURL = claims.Audience[0] } @@ -442,6 +462,7 @@ func (f *revokeFlow) Revoke(ctx *cli.Context, serial, token string) error { RootCAs: rootCAs, PreferServerCipherSuites: true, Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, }, } } diff --git a/command/ca/root.go b/command/ca/root.go index f5ddb032..9bbaaa2c 100644 --- a/command/ca/root.go +++ b/command/ca/root.go @@ -6,17 +6,18 @@ import ( "strings" "github.com/pkg/errors" + "github.com/urfave/cli" "github.com/smallstep/certificates/ca" - "github.com/smallstep/cli/crypto/pemutil" + "github.com/smallstep/cli-utils/command" + "github.com/smallstep/cli-utils/errs" + "github.com/smallstep/cli-utils/ui" + "go.step.sm/crypto/pemutil" + "github.com/smallstep/cli/flags" - "github.com/smallstep/cli/ui" - "github.com/urfave/cli" - "go.step.sm/cli-utils/command" - "go.step.sm/cli-utils/errs" ) -func rootComand() cli.Command { +func rootCommand() cli.Command { return cli.Command{ Name: "root", Action: command.ActionFunc(rootAction), diff --git a/command/ca/sign.go b/command/ca/sign.go index 0463d4eb..5129aa89 100644 --- a/command/ca/sign.go +++ b/command/ca/sign.go @@ -5,31 +5,33 @@ import ( "strings" "github.com/pkg/errors" + "github.com/urfave/cli" + "github.com/smallstep/certificates/api" - "github.com/smallstep/cli/crypto/pemutil" + "github.com/smallstep/cli-utils/command" + "github.com/smallstep/cli-utils/errs" + "github.com/smallstep/cli-utils/ui" + "go.step.sm/crypto/pemutil" + "github.com/smallstep/cli/flags" "github.com/smallstep/cli/token" - "github.com/smallstep/cli/ui" "github.com/smallstep/cli/utils/cautils" - "github.com/urfave/cli" - "go.step.sm/cli-utils/command" - "go.step.sm/cli-utils/errs" ) func signCertificateCommand() cli.Command { return cli.Command{ Name: "sign", Action: command.ActionFunc(signCertificateAction), - Usage: "generate a new certificate signing a certificate request", + Usage: "generate a new certificate from signing a certificate request", UsageText: `**step ca sign** [**--token**=] [**--issuer**=] [**--provisioner-password-file=] [**--not-before**=] [**--not-after**=] [**--set**=] [**--set-file**=] [**--acme**=] [**--standalone**] [**--webroot**=] [**--contact**=] [**--http-listen**=
] [**--console**] -[**--x5c-cert**=] [**--x5c-key**=] -[**--k8ssa-token-path**=] -[**--ca-url**=] [**--root**=] [**--context**=]`, +[**--x5c-cert**=] [**--x5c-key**=] [**--k8ssa-token-path**=] +[**--offline**] [**--password-file**=] [**--ca-url**=] +[**--root**=] [**--context**=]`, Description: `**step ca sign** command signs the given csr and generates a new certificate. ## POSITIONAL ARGUMENTS @@ -60,6 +62,12 @@ files, certificates, and keys created with **step ca init**: $ step ca sign --offline internal internal.csr internal.crt ''' +Sign a new certificate using the offline mode with additional flag to avoid +console prompts: +''' +$ step ca sign --offline --password-file ./pass.txt internal internal.csr internal.crt +''' + Sign a new certificate using an X5C provisioner: NOTE: You must have a X5C provisioner configured (using **step ca provisioner add**). ''' @@ -117,9 +125,14 @@ $ step ca sign foo.csr foo.crt \ flags.TemplateSetFile, flags.Force, flags.Offline, - consoleFlag, + flags.PasswordFile, + flags.Console, + flags.KMSUri, flags.X5cCert, flags.X5cKey, + flags.X5cChain, + flags.NebulaCert, + flags.NebulaKey, acmeFlag, acmeStandaloneFlag, acmeWebrootFlag, @@ -164,7 +177,7 @@ func signCertificateAction(ctx *cli.Context) error { } // certificate flow unifies online and offline flows on a single api - flow, err := cautils.NewCertificateFlow(ctx) + flow, err := cautils.NewCertificateFlow(ctx, cautils.WithCertificateRequest(csr)) if err != nil { return err } @@ -174,15 +187,14 @@ func signCertificateAction(ctx *cli.Context) error { if ctx.IsSet("acme") { return cautils.ACMESignCSRFlow(ctx, csr, crtFile, "") } - sans := mergeSans(ctx, csr) + sans := ctx.StringSlice("san") + sans = mergeSans(sans, csr) if tok, err = flow.GenerateToken(ctx, csr.Subject.CommonName, sans); err != nil { - switch k := err.(type) { - // Use the ACME flow with the step certificate authority. - case *cautils.ErrACMEToken: - return cautils.ACMESignCSRFlow(ctx, csr, crtFile, k.Name) - default: - return err + var acmeTokenErr *cautils.ACMETokenError + if errors.As(err, &acmeTokenErr) { + return cautils.ACMESignCSRFlow(ctx, csr, crtFile, acmeTokenErr.Name) } + return err } } @@ -210,10 +222,10 @@ func signCertificateAction(ctx *cli.Context) error { return nil } -func mergeSans(ctx *cli.Context, csr *x509.CertificateRequest) []string { +func mergeSans(sans []string, csr *x509.CertificateRequest) []string { uniq := make([]string, 0) m := make(map[string]bool) - for _, s := range ctx.StringSlice("san") { + for _, s := range sans { if _, ok := m[s]; !ok { uniq = append(uniq, s) m[s] = true @@ -238,5 +250,12 @@ func mergeSans(ctx *cli.Context, csr *x509.CertificateRequest) []string { m[s] = true } } + for _, u := range csr.URIs { + s := u.String() + if _, ok := m[s]; !ok { + uniq = append(uniq, s) + m[s] = true + } + } return uniq } diff --git a/command/ca/sign_test.go b/command/ca/sign_test.go new file mode 100644 index 00000000..24c1c1f8 --- /dev/null +++ b/command/ca/sign_test.go @@ -0,0 +1,87 @@ +package ca + +import ( + "crypto/x509" + "net" + "net/url" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func mustParseURI(t *testing.T, u string) (parsed *url.URL) { + t.Helper() + parsed, err := url.Parse(u) + if err != nil { + t.Fatal(err) + } + return +} + +func Test_mergeSans(t *testing.T) { + type args struct { + sans []string + csr *x509.CertificateRequest + } + tests := []struct { + name string + args args + want []string + }{ + { + name: "empty", + args: args{ + sans: nil, + csr: &x509.CertificateRequest{}, + }, + want: []string{}, + }, + { + name: "context-only", + args: args{ + sans: []string{"www.example.com"}, + csr: &x509.CertificateRequest{}, + }, + want: []string{"www.example.com"}, + }, + { + name: "csr-only", + args: args{ + sans: nil, + csr: &x509.CertificateRequest{ + DNSNames: []string{"www.example.com"}, + }, + }, + want: []string{"www.example.com"}, + }, + { + name: "full", + args: args{ + sans: []string{"www1.test.local", "mail+1@local", "127.0.0.1", "https://www1.test.local", "www1.test.local", "mail+1@local", "127.0.0.1", "https://www1.test.local"}, + csr: &x509.CertificateRequest{ + DNSNames: []string{"www.example.com", "www1.test.local", "www.example.com"}, + IPAddresses: []net.IP{ + net.ParseIP("127.0.0.2"), + net.ParseIP("127.0.0.1"), + net.ParseIP("127.0.0.2"), + }, + EmailAddresses: []string{"mail+2@local", "mail+1@local", "mail+2@local"}, + URIs: []*url.URL{ + mustParseURI(t, "https://www2.test.local"), + mustParseURI(t, "https://www1.test.local"), + mustParseURI(t, "https://www2.test.local"), + }, + }, + }, + want: []string{"www1.test.local", "mail+1@local", "127.0.0.1", "https://www1.test.local", + "www.example.com", "127.0.0.2", "mail+2@local", "https://www2.test.local"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := mergeSans(tt.args.sans, tt.args.csr); !cmp.Equal(tt.want, got) { + t.Errorf("mergeSans() diff =\n%s", cmp.Diff(tt.want, got)) + } + }) + } +} diff --git a/command/ca/token.go b/command/ca/token.go index 195c0b5f..e074171c 100644 --- a/command/ca/token.go +++ b/command/ca/token.go @@ -4,23 +4,23 @@ import ( "fmt" "os" + "github.com/pkg/errors" + "github.com/urfave/cli" + "github.com/smallstep/certificates/api" "github.com/smallstep/certificates/pki" + "github.com/smallstep/cli-utils/command" + "github.com/smallstep/cli-utils/errs" + "github.com/smallstep/cli-utils/fileutil" + "go.step.sm/crypto/pemutil" + "golang.org/x/crypto/ssh" + "github.com/smallstep/cli/flags" "github.com/smallstep/cli/utils" "github.com/smallstep/cli/utils/cautils" - "github.com/urfave/cli" - "go.step.sm/cli-utils/command" - "go.step.sm/cli-utils/errs" ) func tokenCommand() cli.Command { - // Avoid the conflict with --not-before --not-after - certNotBeforeFlag := flags.NotBefore - certNotAfterFlag := flags.NotAfter - certNotBeforeFlag.Name = "cert-not-before" - certNotAfterFlag.Name = "cert-not-after" - return cli.Command{ Name: "token", Action: command.ActionFunc(tokenAction), @@ -30,11 +30,13 @@ func tokenCommand() cli.Command { [**--cert-not-before**=] [**--cert-not-after**=] [**--not-before**=] [**--not-after**=] [**--password-file**=] [**--provisioner-password-file**=] -[**--output-file**=] [**--key**=] [**--san**=] [**--offline**] -[**--revoke**] [**--x5c-cert**=] [**--x5c-key**=] +[**--output-file**=] [**--kms**=uri] [**--key**=] [**--san**=] [**--offline**] +[**--revoke**] [**--x5c-cert**=] [**--x5c-key**=] [**--x5c-insecure**] [**--sshpop-cert**=] [**--sshpop-key**=] +[**--cnf**=] [**--cnf-file**=] [**--ssh**] [**--host**] [**--principal**=] [**--k8ssa-token-path**=] -[**--ca-url**=] [**--root**=] [**--context**=]`, +[**--ca-url**=] [**--root**=] [**--context**=] +[**--set**=] [**--set-file**=]`, Description: `**step ca token** command generates a one-time token granting access to the certificates authority. @@ -88,6 +90,18 @@ Get a new token that becomes valid in 30 minutes and expires 5 minutes after tha $ step ca token --not-before 30m --not-after 35m internal.example.com ''' +Get a new token with a confirmation claim to enforce a given CSR fingerprint: +''' +$ step certificate fingerprint --format base64-url-raw internal.csr +PJLNhtQoBE1yGN_ZKzr4Y2U5pyqIGiyyszkoz2raDOw +$ step ca token --cnf PJLNhtQoBE1yGN_ZKzr4Y2U5pyqIGiyyszkoz2raDOw internal.smallstep.com +''' + +Get a new token with a confirmation claim to enforce the use of a given CSR: +''' +step ca token --cnf-file internal.csr internal.smallstep.com +''' + Get a new token signed with the given private key, the public key must be configured in the certificate authority: ''' @@ -137,10 +151,46 @@ $ step ca token max@smallstep.com --ssh Get a new token for an SSH host certificate: ''' $ step ca token my-remote.hostname --ssh --host +''' + +Get a new token with a confirmation claim to enforce the use of a given public key: +''' +step ca token --ssh --host --cnf-file internal.pub internal.smallstep.com +''' + +Generate a renew token and use it in a renew after expiry request: +''' +$ TOKEN=$(step ca token --x5c-cert internal.crt --x5c-key internal.key --renew internal.example.com) +$ curl -X POST -H "Authorization: Bearer $TOKEN" https://ca.example.com/1.0/renew +''' + +Generate a JWK provisioner token using a key in a YubiKey: +''' +$ step ca token --kms yubikey:pin-value=123456 --key yubikey:slot-id=82 internal.example.com +''' + +Generate an X5C provisioner token using a certificate in a YubiKey. Note that a +YubiKey does not support storing a certificate bundle. To make it work, you must +add the intermediate and the root in the provisioner configuration: +''' +$ step ca token \ + --x5c-cert yubikey:slot-id=82 \ + --x5c-key 'yubikey:slot-id=82?pin=value=123456' \ + internal.example.com +''' + +Generate a token with custom data in the "user" claim. The example below can be +accessed in a template as **.Token.user.field**, rendering to the string +"value". + +This is distinct from **.Insecure.User**: any attributes set using this option +are added to a claim named "user" in the signed JWT produced by this command. +This data may therefore be considered trusted (insofar as the token itself is +trusted). +''' +$ step ca token --set field=value internal.example.com '''`, Flags: []cli.Flag{ - certNotAfterFlag, - certNotBeforeFlag, provisionerKidFlag, cli.StringSliceFlag{ Name: "san", @@ -159,15 +209,37 @@ multiple principals.`, sshHostFlag, flags.CaConfig, flags.Force, - flags.NotAfter, - flags.NotBefore, + cli.StringFlag{ + Name: "not-before", + Usage: `The when the token's validity period starts. If a