diff --git a/.github/workflows/build_releases.yml b/.github/workflows/build_releases.yml index d1a0d90..c6e902a 100644 --- a/.github/workflows/build_releases.yml +++ b/.github/workflows/build_releases.yml @@ -8,40 +8,10 @@ on: env: GITHUB_REF: ${{ github.ref }} - GO_VERSION: '1.22.1' jobs: - build-common: - runs-on: ubuntu-latest - - steps: - - name: Checkout Main Repo - uses: actions/checkout@v4 - with: - repository: gregtwallace/apc-p15-tool - ref: ${{ env.GITHUB_REF }} - fetch-depth: 0 - - - name: Save README - uses: actions/upload-artifact@v4 - with: - name: README.md - path: ./README.md - - - name: Save LICENSE - uses: actions/upload-artifact@v4 - with: - name: LICENSE.md - path: ./LICENSE.md - - - name: Save CHANGELOG - uses: actions/upload-artifact@v4 - with: - name: CHANGELOG.md - path: ./CHANGELOG.md - - build-linux-arm64: - runs-on: ubuntu-latest + build-all: + runs-on: ubuntu-24.04 steps: - name: Checkout Repo uses: actions/checkout@v4 @@ -50,255 +20,17 @@ jobs: ref: ${{ env.GITHUB_REF }} fetch-depth: 0 - - name: Update apt - run: sudo apt update - - - name: Install cross-compiler for linux/arm64 - run: sudo apt-get -y install gcc-aarch64-linux-gnu - - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '${{ env.GO_VERSION }}' + go-version-file: 'go.mod' - - name: Build Tool - run: go build -o ./apc-p15-tool -v ./cmd/tool - env: - GOOS: linux - GOARCH: arm64 - CC: aarch64-linux-gnu-gcc - CGO_ENABLED: 0 + - name: Build All + run: | + python ./build_release.py - - name: Save Compiled Binary + - name: Save Zip of all targets uses: actions/upload-artifact@v4 with: - name: apc-p15-tool-linux-arm64 - path: ./apc-p15-tool - - - name: Build Install Only - run: go build -o ./apc-p15-install -v ./cmd/install_only - env: - GOOS: linux - GOARCH: arm64 - CC: aarch64-linux-gnu-gcc - CGO_ENABLED: 0 - - - name: Save Compiled Binary - uses: actions/upload-artifact@v4 - with: - name: apc-p15-install-linux-arm64 - path: ./apc-p15-install - - build-linux-amd64: - runs-on: ubuntu-latest - steps: - - name: Checkout Backend Repo - uses: actions/checkout@v4 - with: - repository: gregtwallace/apc-p15-tool - ref: ${{ env.GITHUB_REF }} - fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: '${{ env.GO_VERSION }}' - - - name: Build Tool - run: go build -o ./apc-p15-tool -v ./cmd/tool - env: - GOOS: linux - GOARCH: amd64 - CGO_ENABLED: 0 - - - name: Save Compiled Binary - uses: actions/upload-artifact@v4 - with: - name: apc-p15-tool-linux-amd64 - path: ./apc-p15-tool - - - name: Build Install Only - run: go build -o ./apc-p15-install -v ./cmd/install_only - env: - GOOS: linux - GOARCH: amd64 - CGO_ENABLED: 0 - - - name: Save Compiled Binary - uses: actions/upload-artifact@v4 - with: - name: apc-p15-install-linux-amd64 - path: ./apc-p15-install - - build-windows-amd64: - runs-on: windows-latest - steps: - - name: Checkout Backend Repo - uses: actions/checkout@v4 - with: - repository: gregtwallace/apc-p15-tool - ref: ${{ env.GITHUB_REF }} - fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: '${{ env.GO_VERSION }}' - - - name: Build Tool - run: go build -o ./apc-p15-tool.exe -v ./cmd/tool - env: - GOOS: windows - GOARCH: amd64 - CGO_ENABLED: 0 - - - name: Save Compiled Binary - uses: actions/upload-artifact@v4 - with: - name: apc-p15-tool-windows-amd64 - path: ./apc-p15-tool.exe - - - name: Build Install Only - run: go build -o ./apc-p15-install.exe -v ./cmd/install_only - env: - GOOS: windows - GOARCH: amd64 - CGO_ENABLED: 0 - - - name: Save Compiled Binary - uses: actions/upload-artifact@v4 - with: - name: apc-p15-install-windows-amd64 - path: ./apc-p15-install.exe - - release-file-linux-arm64: - needs: [build-common, build-linux-arm64] - runs-on: ubuntu-latest - - steps: - - name: Make release directory - run: mkdir ./release - - - name: Download Tool Binary - uses: actions/download-artifact@v4 - with: - name: apc-p15-tool-linux-arm64 - path: ./release - - - name: Download Install Binary - uses: actions/download-artifact@v4 - with: - name: apc-p15-install-linux-arm64 - path: ./release - - - name: Download README - uses: actions/download-artifact@v4 - with: - name: README.md - path: ./release - - - name: Download LICENSE - uses: actions/download-artifact@v4 - with: - name: LICENSE.md - path: ./release - - - name: Download CHANGELOG - uses: actions/download-artifact@v4 - with: - name: CHANGELOG.md - path: ./release - - - name: Save Release - uses: actions/upload-artifact@v4 - with: - name: apc-p15-tool_linux_arm64 - path: ./release - - release-file-linux-amd64: - needs: [build-common, build-linux-amd64] - runs-on: ubuntu-latest - - steps: - - name: Make release directory - run: mkdir ./release - - - name: Download Tool Binary - uses: actions/download-artifact@v4 - with: - name: apc-p15-tool-linux-amd64 - path: ./release - - - name: Download Install Binary - uses: actions/download-artifact@v4 - with: - name: apc-p15-install-linux-amd64 - path: ./release - - - name: Download README - uses: actions/download-artifact@v4 - with: - name: README.md - path: ./release - - - name: Download LICENSE - uses: actions/download-artifact@v4 - with: - name: LICENSE.md - path: ./release - - - name: Download CHANGELOG - uses: actions/download-artifact@v4 - with: - name: CHANGELOG.md - path: ./release - - - name: Save Release - uses: actions/upload-artifact@v4 - with: - name: apc-p15-tool_linux_amd64 - path: ./release - - release-file-windows-amd64: - needs: [build-common, build-windows-amd64] - runs-on: ubuntu-latest - - steps: - - name: Make release directory - run: mkdir ./release - - - name: Download Tool Binary - uses: actions/download-artifact@v4 - with: - name: apc-p15-tool-windows-amd64 - path: ./release - - - name: Download Install Binary - uses: actions/download-artifact@v4 - with: - name: apc-p15-install-windows-amd64 - path: ./release - - - name: Download README - uses: actions/download-artifact@v4 - with: - name: README.md - path: ./release - - - name: Download LICENSE - uses: actions/download-artifact@v4 - with: - name: LICENSE.md - path: ./release - - - name: Download CHANGELOG - uses: actions/download-artifact@v4 - with: - name: CHANGELOG.md - path: ./release - - - name: Save Release - uses: actions/upload-artifact@v4 - with: - name: apc-p15-tool_windows_amd64 - path: ./release + name: apc-p15-tool-release + path: ./_out/_release diff --git a/CHANGELOG.md b/CHANGELOG.md index f205de6..c8a6ff5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,84 @@ # APC P15 Tool Changelog +## [v1.3.0] - 2025-06-23 + +This release attempts to detect and warn of possible incompatibilies with a +spcecified certificate. NMCs do not warn or error when a bad file is installed, +instead they silently fail and generally just generate a new self-signed +certificate. This release checks some properties of the specified certificate +and produces warning messages that can be referenced if the cert installation +appears to work but ultimately doesn't prododuce the expected result. + +- Add warnings based on key type, signature algorithm, validity dates, and + extensions. +- Minor lint. + + +## [v1.2.3] - 2025-06-19 + +Minor updates to the application. Large updates to the build process to +improve building, releasing, and maintainability. + +- Go updated to 1.24.4 and all dependencies updated. +- Added FreeBSD arm64 and amd64 builds. +- Build process overhauled for simplicity. Build is now OS agnostic. PowerShell + script was removed and replaced with a python script. +- Build instructions added to README. +- GitHub build action now only runs in one Ubuntu container and cross-compiles. +- Release windows and macos as zip files and all others as gztar. +- Add file permissions for non-windows and non-macos releases. + + +## [v1.2.2] - 2025-04-22 + +All dependencies updated. + +Add darwin arm64 and amd64 builds. + + +## [v1.2.1] - 2025-03-17 + +Fix time check for UPS when it is set to GMT timezone. + +All dependencies updated. + + +## [v1.2.0] - 2025-01-27 + +Add a new feature to `install` that checks the time of the UPS to confirm +it is accurate. A log message is added that advises either way. Even if +the check fails, the install still proceeds with attempting to install +the new certificate. + +Dependencies were also all updated. + + +## [v1.1.0] - 2024-09-17 + +> [!IMPORTANT] +> The flag `apchost` on the `install` command has been renamed to +> `hostname`. This flag should contain the hostname only. If a non- +> default SSH port is needed, specify it in the `sshport` flag. + +This version brings support for for RSA 4,092 bit and EC keys. These +keys are only compatible with NMC3 running newer firmwares. To know +if your firmware is new enough, SSH into your UPS and type `ssh` and enter. +If the UPS responds `Command Not Found` the firmware is too old or +otherwise incompatible. + +This version also adds a post `install` check that connects to the web +ui and verifies the certificate served is the expected one. You can +specify a non standard ssl port with the `sslport` flag or skip the check +entirely with the `skipverify` flag. + + +## [v1.0.0] - 2024-07-01 + +First official stable release. + +Fixes Go version in Github action. + + ## [v0.5.3] - 2024-06-24 Add 3,072 bit RSA key support. diff --git a/README.md b/README.md index 541a046..9286344 100644 --- a/README.md +++ b/README.md @@ -53,30 +53,88 @@ This project aims to solve all of these problems by accepting the most common key and cert file format (PEM) and by being 100% open source and licensed under the GPL-3.0 license. -## Compatibility Notice +### Key Types and Sizes + +Ensure you select an appropriate key! + +NMC2 is extremely picky about the key type and size it supports. NMC3 is a bit +more flexible. Beware, some ACME clients will generate an ECDSA key by default +which is NOT supported by NMC2. + +NMC2: +- RSA 1,024, 2,048, 3,072* bit lengths. + +NMC3*: +- RSA 1,024, 2,048, 3,072, and 4,092 bit lengths. +- ECDSA curves P-256, P-384, and P-521. + +\* 3,072 bit length is not officially supported by my NMC2, but appears to work + fine. + +\* The additional key types supported by NMC3 require newer firmware on the + device. I am unsure what the version cutoff is, but you can check support + by connecting to the UPS via SSH and typing `ssl`. If `Command Not Found` + is returned, the firmware is too old and only the key types listed under + NMC2 will work. + +1,024 bit RSA is no longer considered completely secure; avoid keys of +this size if possible. Most (all?) public ACME services won't accept keys +of this size anyway. + +### Compatibility Notice Both NMC2 and NMC3 devices should be fully supported. However, I have one NMC2 device in a home lab and have no way to guarantee success in all cases. -Only RSA 1,024, 2,048, and 3,072 bit keys are accepted. 1,024 bit RSA is no -longer considered completely secure; avoid keys of this size if possible. Most -(all?) public ACME services won't accept keys of this size anyway. - -NMC2 does not officially support the 3,072 bit key size, however, it works fine -on my NMC2. If you use this size and it doesn't work on your NMC2, try a 2,048 -bit key instead. Later versions of the NMC3 firmware support RSA 4,096 and -ECDSA keys, but this tool does not. ECDSA was not included in APC's proprietary -tool, and as such I have no way to generate files to reverse engineer. - My setup (and therefore the testing setup) is: - APC Smart-UPS 1500VA RM 2U SUA1500RM2U (Firmware Revision 667.18.D) -- AP9631 NMC2 Hardware Revision 05 running AOS v7.0.4 and Boot Monitor +- AP9631 NMC2 Hardware Revision 05 running AOS v7.1.2 and Boot Monitor v1.0.9. -If you have problems, please post the log in an issue and I can try to fix it -but it may be difficult without your particular hardware to test with. +Generally, if there is a compatibility issue, there is a good chance you will +not see an error. Rather, the NMC will silently fail and you'll only know +something went wrong because the NMC's certificate didn't update, or it regenerated +a self-signed certificate that you'll see upon your next connection attempt. +I've tried to add some `WARNING` messages to the tool to indicate what might +be going wrong, but the list is definitely not exhaustive. -In particular, if you are experiencing `ssh: handshake failed:` first try +### Troubleshooting + +Suggested troubleshooting steps: +- Review the `Key Types and Sizes` and `Compatibility Notice` sections of this + README. +- Update your NMC's firmware to the latest version. +- Read this tool's output, look specifically for any `WARNING` messages and + adjust your certificate accordingly. +- Test using an RSA 2048 bit key to obtain a certificate from Let's Encrypt. + Their certificates are known to work with NMC. +- Use the official NMC Security Wizard to verify you can create a working + certificate and load it into your NMC. If the official tool does not work + switching to this tool won't help. + +If you have tried all of these steps and are still experiencing a problem, +you may open an Issue on GitHub. + +Include: +- The full command you are running that is causing the problem. +- The full log of this tool's output when you run the command. Append the + `--debug` flag to your command to get the debug output. + +Keep in mind, I am one person with one specific hardware setup. I may not +be able to help you. + +#### NMC3 Install `ssh: parse error in message type 53` Error + +Configuring a `System Message` on an NMC3 breaks the install function. I do +not have an NMC3 and after doing some code review it is highly unlikely I'll +be able to fix this. Don't use a `System Message` if the install feature is +important to you. + +see: https://github.com/gregtwallace/apc-p15-tool/issues/14 + +#### Install `ssh: handshake failed` Error + +If you are experiencing `ssh: handshake failed:` first try using the `--insecurecipher` flag. If this works, you should upgrade your NMC to a newer firmware which includes secure ciphers. You should NOT automate your environment using this flag as SSH over these ciphers is broken and @@ -127,7 +185,7 @@ disk. It instead installs the files directly on the NMC. Logic automatically deduces if the device is an NMC2 or NMC3 and performs the appropriate installation steps. -e.g. `./apc-p15-tool install --keyfile ./apckey.pem --certfile ./apccert.pem --apchost myapc.example.com:22 --username apc --password someSecret --fingerprint 123abc` +e.g. `./apc-p15-tool install --keyfile ./apckey.pem --certfile ./apccert.pem --hostname myapc.example.com --username apc --password someSecret --fingerprint 123abc` ## Note About Install Automation @@ -152,6 +210,20 @@ separate script.  +## Building + +Python3, Go, and git all must be installed to run the build script. + +Once the dependencies are installed, clone this repo and run +`python build_release.py`. If you only want to build for certain OS or +ARCH targets, edit the `targets` array in the `build_release.py` file +before running it. + +## Links + +@Owl-Tec's write up using this tool with ACDS: +https://owltec.ca/Windows+Server/Deploying+An+Internal+HTTPS+Certificate+for+a+UPS+APC+with+ADCS+(Active+Directory+Certificate+Services)+with+APC+P15+Tool + ## Thanks Special thanks to the following people and resources which helped me diff --git a/build.ps1 b/build.ps1 deleted file mode 100644 index 7147a33..0000000 --- a/build.ps1 +++ /dev/null @@ -1,36 +0,0 @@ -# Parent dir is root -$scriptDir = Get-Location -$outDir = Join-Path -Path $scriptDir -ChildPath "/_out" - -# Windows x64 -$env:GOARCH = "amd64" -$env:GOOS = "windows" -$env:CGO_ENABLED = 0 -go build -o $outDir/apc-p15-tool-amd64.exe ./cmd/tool - -$env:GOARCH = "amd64" -$env:GOOS = "windows" -$env:CGO_ENABLED = 0 -go build -o $outDir/apc-p15-install-amd64.exe ./cmd/install_only - -# Linux x64 -$env:GOARCH = "amd64" -$env:GOOS = "linux" -$env:CGO_ENABLED = 0 -go build -o $outDir/apc-p15-tool-amd64 ./cmd/tool - -$env:GOARCH = "amd64" -$env:GOOS = "linux" -$env:CGO_ENABLED = 0 -go build -o $outDir/apc-p15-install-amd64 ./cmd/install_only - -# Linux arm64 -$env:GOARCH = "arm64" -$env:GOOS = "linux" -$env:CGO_ENABLED = 0 -go build -o $outDir/apc-p15-tool-arm64 ./cmd/tool - -$env:GOARCH = "arm64" -$env:GOOS = "linux" -$env:CGO_ENABLED = 0 -go build -o $outDir/apc-p15-install-arm64 ./cmd/install_only diff --git a/build_release.py b/build_release.py new file mode 100644 index 0000000..d3892e7 --- /dev/null +++ b/build_release.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +import os.path +import shutil +import subprocess +import tarfile + +# Configuration +# output path (relative to this script) +outRelativeDir = "_out" + +# target strings must be in the format: +# `GOOS_GOARCH` +# see: https://github.com/golang/go/blob/master/src/internal/syslist/syslist.go +# or unofficially: https://gist.github.com/asukakenji/f15ba7e588ac42795f421b48b8aede63 +targets = [ + "windows_amd64", + "linux_amd64", + "linux_arm64", + "darwin_amd64", + "darwin_arm64", + "freebsd_amd64", + "freebsd_arm64", +] + +### + +# Script +# relative dir is root +scriptDir = dirname = os.path.dirname(__file__) +outBaseDir = os.path.join(scriptDir, outRelativeDir) +releaseDir = os.path.join(outBaseDir, "_release") + +# recreate paths +if os.path.exists(outBaseDir): + shutil.rmtree(outBaseDir) +os.makedirs(outBaseDir) +os.makedirs(releaseDir) + +# get version number / tag +gitTag = subprocess.check_output(["git", "describe", "--tags", "--abbrev=0"]).decode('utf-8').strip() + +# loop through and build all targets +for target in targets: + # environment vars + split = target.split("_") + GOOS = split[0] + GOARCH = split[1] + os.environ["GOOS"] = GOOS + os.environ["GOARCH"] = GOARCH + os.environ["CGO_ENABLED"] = "0" + + # send build product to GOOS_GOARCH subfolders + targetOutDir = os.path.join(outBaseDir, target) + if not os.path.exists(targetOutDir): + os.makedirs(targetOutDir) + + # special case for windows to add file extensions + extension = "" + if GOOS.lower() == "windows": + extension = ".exe" + + # build binary and install only binary + subprocess.run(["go", "build", "-o", f"{targetOutDir}/apc-p15-tool{extension}", "./cmd/tool"]) + subprocess.run(["go", "build", "-o", f"{targetOutDir}/apc-p15-install{extension}", "./cmd/install_only"]) + + # copy other important files for release + shutil.copy("README.md", targetOutDir) + shutil.copy("CHANGELOG.md", targetOutDir) + shutil.copy("LICENSE.md", targetOutDir) + + # compress release file + # special case for windows & mac to use zip format + if GOOS.lower() == "windows" or GOOS.lower() == "darwin": + shutil.make_archive(f"{releaseDir}/apc-p15-tool-{gitTag}_{target}", "zip", targetOutDir) + else: + # for others, use gztar and set permissions on the files + + # filter for setting permissions + def set_permissions(tarinfo): + if tarinfo.name == "apc-p15-tool" or tarinfo.name == "apc-p15-install": + tarinfo.mode = 0o0755 + else: + tarinfo.mode = 0o0644 + return tarinfo + + # make tar + with tarfile.open(f"{releaseDir}/apc-p15-tool-{gitTag}_{target}.tar.gz", "w:gz") as tar: + for file in os.listdir(targetOutDir): + tar.add(os.path.join(targetOutDir, file), arcname=file, recursive=False, filter=set_permissions) diff --git a/go.mod b/go.mod index ad1bc05..4848582 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,14 @@ module apc-p15-tool -go 1.22.4 +go 1.24.4 require ( github.com/peterbourgon/ff/v4 v4.0.0-alpha.4 - github.com/sigurn/crc16 v0.0.0-20211026045750-20ab5afb07e3 - golang.org/x/crypto v0.18.0 + github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1 + golang.org/x/crypto v0.39.0 ) -require golang.org/x/sys v0.16.0 // indirect +require golang.org/x/sys v0.33.0 // indirect replace apc-p15-tool/cmd/install_only => /cmd/install_only diff --git a/go.sum b/go.sum index 96676f0..cd5913b 100644 --- a/go.sum +++ b/go.sum @@ -2,13 +2,13 @@ github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/peterbourgon/ff/v4 v4.0.0-alpha.4 h1:aiqS8aBlF9PsAKeMddMSfbwp3smONCn3UO8QfUg0Z7Y= github.com/peterbourgon/ff/v4 v4.0.0-alpha.4/go.mod h1:H/13DK46DKXy7EaIxPhk2Y0EC8aubKm35nBjBe8AAGc= -github.com/sigurn/crc16 v0.0.0-20211026045750-20ab5afb07e3 h1:aQKxg3+2p+IFXXg97McgDGT5zcMrQoi0EICZs8Pgchs= -github.com/sigurn/crc16 v0.0.0-20211026045750-20ab5afb07e3/go.mod h1:9/etS5gpQq9BJsJMWg1wpLbfuSnkm8dPF6FdW2JXVhA= -golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= -golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= -golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= -golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE= -golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1 h1:NVK+OqnavpyFmUiKfUMHrpvbCi2VFoWTrcpI7aDaJ2I= +github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1/go.mod h1:9/etS5gpQq9BJsJMWg1wpLbfuSnkm8dPF6FdW2JXVhA= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/img/apc-p15-tool.png b/img/apc-p15-tool.png index 807fb84..c537585 100644 Binary files a/img/apc-p15-tool.png and b/img/apc-p15-tool.png differ diff --git a/pkg/apcssh/cmd_gettime.go b/pkg/apcssh/cmd_gettime.go new file mode 100644 index 0000000..139b0ba --- /dev/null +++ b/pkg/apcssh/cmd_gettime.go @@ -0,0 +1,62 @@ +package apcssh + +import ( + "fmt" + "regexp" + "strings" + "time" +) + +// GetTime sends the APC `system` command and then attempts to parse the +// response to determine the UPS current date/time. +func (cli *Client) GetTime() (time.Time, error) { + result, err := cli.cmd("date") + if err != nil { + return time.Time{}, fmt.Errorf("apcssh: failed to get time (%s)", err) + } else if !strings.EqualFold(result.code, "e000") { + return time.Time{}, fmt.Errorf("apcssh: failed to get time (%s: %s)", result.code, result.codeText) + } + + // capture each portion of the date information + regex := regexp.MustCompile(`Date:\s*(\S*)\s*[\r\n]Time:\s*(\S*)\s*[\r\n]Format:\s*(\S*)\s*[\r\n]Time Zone:\s*(\S*)\s*[\r\n]?`) + datePieces := regex.FindStringSubmatch(result.resultText) + if len(datePieces) != 5 { + return time.Time{}, fmt.Errorf("apcssh: failed to get time (length of datetime value pieces was %d (expected: 5))", len(datePieces)) + } + dateVal := datePieces[1] + timeVal := datePieces[2] + formatUPSVal := datePieces[3] + timeZoneVal := datePieces[4] + + // GMT time requires + prefix + // APC UPS fails to use the required +, so add it + if timeZoneVal == "00:00" { + timeZoneVal = "+" + timeZoneVal + } + + // known APC UPS format strings + dateFormatVal := "" + switch formatUPSVal { + case "mm/dd/yyyy": + dateFormatVal = "01/02/2006" + case "dd.mm.yyyy": + dateFormatVal = "02.01.2006" + case "mmm-dd-yy": + dateFormatVal = "Jan-02-06" + case "dd-mmm-yy": + dateFormatVal = "02-Jan-06" + case "yyyy-mm-dd": + dateFormatVal = "2006-01-02" + + default: + return time.Time{}, fmt.Errorf("apcssh: failed to get time (ups returned unknown format string (%s)", formatUPSVal) + } + + // convert to time.Time + t, err := time.Parse(dateFormatVal+" 15:04:05 -07:00", dateVal+" "+timeVal+" "+timeZoneVal) + if err != nil { + return time.Time{}, fmt.Errorf("apcssh: failed to get time (time parse failed: %s)", err) + } + + return t, nil +} diff --git a/pkg/apcssh/ssl.go b/pkg/apcssh/ssl.go index 2eb3bce..18bff70 100644 --- a/pkg/apcssh/ssl.go +++ b/pkg/apcssh/ssl.go @@ -1,10 +1,13 @@ package apcssh import ( + "errors" "fmt" "strings" ) +var errSSLMissingData = errors.New("apcssh: ssl cert install: cant install nil data (unsupported key/nmc version/nmc firmware combo?)") + // InstallSSLCert installs the specified p15 key and p15 cert files on the // UPS. It has logic to deduce if the NMC is a newer version (e.g., NMC3 with // newer firmware) and acts accordingly. @@ -29,6 +32,11 @@ func (cli *Client) InstallSSLCert(keyP15 []byte, certPem []byte, keyCertP15 []by // installSSLCertModern installs the SSL key and certificate using the UPS built-in // command `ssl`. This command is not present on older devices (e.g., NMC2) or firmwares. func (cli *Client) installSSLCertModern(keyP15 []byte, certPem []byte) error { + // fail if required data isn't present + if keyP15 == nil || len(keyP15) <= 0 || certPem == nil || len(certPem) <= 0 { + return errSSLMissingData + } + // upload the key P15 file err := cli.UploadSCP("/ssl/nmc.key", keyP15, 0600) if err != nil { @@ -63,6 +71,11 @@ func (cli *Client) installSSLCertModern(keyP15 []byte, certPem []byte) error { // them to a .p15 file on the UPS. This is used for older devices (e.g., NMC2) and // firmwares that do not support the `ssl` command. func (cli *Client) installSSLCertLegacy(keyCertP15 []byte) error { + // fail if required data isn't present + if keyCertP15 == nil || len(keyCertP15) <= 0 { + return errSSLMissingData + } + // upload/install keyCert P15 file err := cli.UploadSCP("/ssl/defaultcert.p15", keyCertP15, 0600) if err != nil { diff --git a/pkg/app/app.go b/pkg/app/app.go index c228b20..151236e 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -12,7 +12,7 @@ import ( ) const ( - appVersion = "0.5.3" + appVersion = "1.3.0" ) // struct for receivers to use common app pieces diff --git a/pkg/app/cmd_create.go b/pkg/app/cmd_create.go index 77f13ee..8791a81 100644 --- a/pkg/app/cmd_create.go +++ b/pkg/app/cmd_create.go @@ -51,11 +51,14 @@ func (app *app) cmdCreate(_ context.Context, args []string) error { } app.stdLogger.Printf("create: apc p15 key file %s written to disk", keyFileName) - err = os.WriteFile(keyCertFileName, apcKeyCertFile, 0600) - if err != nil { - return fmt.Errorf("create: failed to write apc p15 key+cert file (%s)", err) + // skip key+cert if it wasn't generated + if len(apcKeyCertFile) > 0 { + err = os.WriteFile(keyCertFileName, apcKeyCertFile, 0600) + if err != nil { + return fmt.Errorf("create: failed to write apc p15 key+cert file (%s)", err) + } + app.stdLogger.Printf("create: apc p15 key+cert file %s written to disk", keyCertFileName) } - app.stdLogger.Printf("create: apc p15 key+cert file %s written to disk", keyCertFileName) // if debug, write additional debug files (b64 format to make copy/paste into asn1 decoder // easy to do e.g., https://lapo.it/asn1js) @@ -67,19 +70,22 @@ func (app *app) cmdCreate(_ context.Context, args []string) error { } app.debugLogger.Printf("create: apc p15 key file %s written to disk", keyFileNameDebug) - keyCertFileNameDebug := keyCertFileName + ".noheader.b64" - err = os.WriteFile(keyCertFileNameDebug, []byte(base64.StdEncoding.EncodeToString(apcKeyCertFile[apcHeaderLen:])), 0600) - if err != nil { - return fmt.Errorf("create: failed to write apc p15 key+cert file (%s)", err) - } - app.debugLogger.Printf("create: apc p15 key+cert file %s written to disk", keyCertFileNameDebug) + // skip key+cert if it wasn't generated + if len(apcKeyCertFile) > 0 { + keyCertFileNameDebug := keyCertFileName + ".noheader.b64" + err = os.WriteFile(keyCertFileNameDebug, []byte(base64.StdEncoding.EncodeToString(apcKeyCertFile[apcHeaderLen:])), 0600) + if err != nil { + return fmt.Errorf("create: failed to write apc p15 key+cert file (%s)", err) + } + app.debugLogger.Printf("create: apc p15 key+cert file %s written to disk", keyCertFileNameDebug) - keyCertFileNameHeaderDebug := keyCertFileName + ".header.b64" - err = os.WriteFile(keyCertFileNameHeaderDebug, []byte(base64.StdEncoding.EncodeToString(apcKeyCertFile[:apcHeaderLen])), 0600) - if err != nil { - return fmt.Errorf("create: failed to write apc p15 key+cert file (%s)", err) + keyCertFileNameHeaderDebug := keyCertFileName + ".header.b64" + err = os.WriteFile(keyCertFileNameHeaderDebug, []byte(base64.StdEncoding.EncodeToString(apcKeyCertFile[:apcHeaderLen])), 0600) + if err != nil { + return fmt.Errorf("create: failed to write apc p15 key+cert file (%s)", err) + } + app.debugLogger.Printf("create: apc p15 key+cert file header %s written to disk", keyCertFileNameHeaderDebug) } - app.debugLogger.Printf("create: apc p15 key+cert file header %s written to disk", keyCertFileNameHeaderDebug) } diff --git a/pkg/app/cmd_install.go b/pkg/app/cmd_install.go index d3270a4..d94c00c 100644 --- a/pkg/app/cmd_install.go +++ b/pkg/app/cmd_install.go @@ -2,11 +2,18 @@ package app import ( "apc-p15-tool/pkg/apcssh" + "bytes" "context" + "crypto/tls" + "encoding/pem" "errors" "fmt" + "strconv" + "time" ) +const timeLoggingFormat = time.RFC1123Z + // cmdInstall is the app's command to create apc p15 file content from key and cert // pem files and upload the p15 to the specified APC UPS func (app *app) cmdInstall(cmdCtx context.Context, args []string) error { @@ -36,7 +43,9 @@ func (app *app) cmdInstall(cmdCtx context.Context, args []string) error { } // host to install on must be specified - if app.config.install.hostAndPort == nil || *app.config.install.hostAndPort == "" { + if app.config.install.hostname == nil || *app.config.install.hostname == "" || + app.config.install.sshport == nil || *app.config.install.sshport == 0 { + return errors.New("install: failed, apc host not specified") } @@ -55,7 +64,7 @@ func (app *app) cmdInstall(cmdCtx context.Context, args []string) error { // make APC SSH client cfg := &apcssh.Config{ - Hostname: *app.config.install.hostAndPort, + Hostname: *app.config.install.hostname + ":" + strconv.Itoa(*app.config.install.sshport), Username: *app.config.install.username, Password: *app.config.install.password, ServerFingerprint: *app.config.install.fingerprint, @@ -68,6 +77,16 @@ func (app *app) cmdInstall(cmdCtx context.Context, args []string) error { } app.stdLogger.Println("install: connected to ups ssh, installing ssl key and cert...") + // check time - don't fail it time is no good, just do logging here + upsT, err := client.GetTime() + if err != nil { + app.errLogger.Printf("warn: install: failed to fetch UPS time (%s), you should manually verify the time is correct on the UPS", err) + } else if upsT.After(time.Now().Add(1*time.Hour)) || upsT.Before(time.Now().Add(-1*time.Hour)) { + app.errLogger.Printf("warn: install: UPS clock skew detected (this system's time is %s vs. UPS time %s", time.Now().Format(timeLoggingFormat), upsT.Format(timeLoggingFormat)) + } else { + app.stdLogger.Printf("install: UPS clock appears correct (%s)", upsT.Format(timeLoggingFormat)) + } + // install SSL Cert err = client.InstallSSLCert(keyP15, certPem, keyCertP15) if err != nil { @@ -75,7 +94,7 @@ func (app *app) cmdInstall(cmdCtx context.Context, args []string) error { } // installed - app.stdLogger.Printf("install: apc p15 file installed on %s", *app.config.install.hostAndPort) + app.stdLogger.Printf("install: apc p15 file installed on %s", *app.config.install.hostname) // restart UPS webUI if app.config.install.restartWebUI != nil && *app.config.install.restartWebUI { @@ -89,5 +108,48 @@ func (app *app) cmdInstall(cmdCtx context.Context, args []string) error { app.stdLogger.Println("install: sent webui restart command") } + // check the new certificate is installed + if app.config.install.skipVerify != nil && !*app.config.install.skipVerify && + app.config.install.webUISSLPort != nil && *app.config.install.webUISSLPort != 0 { + + app.stdLogger.Println("install: attempting to verify certificate install...") + + // sleep for UPS to finish anything it might be doing + time.Sleep(5 * time.Second) + + // if UPS web UI was restarted, sleep longer + if app.config.install.restartWebUI != nil && *app.config.install.restartWebUI { + app.stdLogger.Println("install: waiting for ups webui restart...") + time.Sleep(25 * time.Second) + } + + // connect to the web UI to get the current certificate + conf := &tls.Config{ + InsecureSkipVerify: true, + } + conn, err := tls.Dial("tcp", *app.config.install.hostname+":"+strconv.Itoa(*app.config.install.webUISSLPort), conf) + if err != nil { + return fmt.Errorf("install: failed to dial webui for verification (%s)", err) + } + defer conn.Close() + + // get top cert + leafCert := conn.ConnectionState().PeerCertificates[0] + if leafCert == nil { + return fmt.Errorf("install: failed to get web ui leaf cert for verification (%s)", err) + } + + // convert pem to DER for comparison + pemBlock, _ := pem.Decode(certPem) + + // verify cert is the correct one + certVerified := bytes.Equal(leafCert.Raw, pemBlock.Bytes) + if !certVerified { + return errors.New("install: web ui leaf cert does not match new cert (your cert may not be compatible with NMC; check for WARNINGs in this tool's output)") + } + + app.stdLogger.Println("install: ups web ui cert verified") + } + return nil } diff --git a/pkg/app/config.go b/pkg/app/config.go index e8ff1fc..7edf216 100644 --- a/pkg/app/config.go +++ b/pkg/app/config.go @@ -33,11 +33,14 @@ type config struct { } install struct { keyCertPemCfg - hostAndPort *string + hostname *string + sshport *int fingerprint *string username *string password *string restartWebUI *bool + webUISSLPort *int + skipVerify *bool insecureCipher *bool } } @@ -68,9 +71,9 @@ func (app *app) getConfig(args []string) error { // create -- subcommand createFlags := ff.NewFlagSet("create").SetParent(rootFlags) - cfg.create.keyPemFilePath = createFlags.StringLong("keyfile", "", "path and filename of the rsa-1024 or rsa-2048 key in pem format") + cfg.create.keyPemFilePath = createFlags.StringLong("keyfile", "", "path and filename of the key in pem format") cfg.create.certPemFilePath = createFlags.StringLong("certfile", "", "path and filename of the certificate in pem format") - cfg.create.keyPem = createFlags.StringLong("keypem", "", "string of the rsa-1024 or rsa-2048 key in pem format") + cfg.create.keyPem = createFlags.StringLong("keypem", "", "string of the key in pem format") cfg.create.certPem = createFlags.StringLong("certpem", "", "string of the certificate in pem format") cfg.create.outFilePath = createFlags.StringLong("outfile", createDefaultOutFilePath, "path and filename to write the key+cert p15 file to") cfg.create.outKeyFilePath = createFlags.StringLong("outkeyfile", createDefaultOutKeyFilePath, "path and filename to write the key p15 file to") @@ -88,20 +91,23 @@ func (app *app) getConfig(args []string) error { // install -- subcommand installFlags := ff.NewFlagSet("install").SetParent(rootFlags) - cfg.install.keyPemFilePath = installFlags.StringLong("keyfile", "", "path and filename of the rsa-1024 or rsa-2048 key in pem format") + cfg.install.keyPemFilePath = installFlags.StringLong("keyfile", "", "path and filename of the key in pem format") cfg.install.certPemFilePath = installFlags.StringLong("certfile", "", "path and filename of the certificate in pem format") - cfg.install.keyPem = installFlags.StringLong("keypem", "", "string of the rsa-1024 or rsa-2048 key in pem format") + cfg.install.keyPem = installFlags.StringLong("keypem", "", "string of the key in pem format") cfg.install.certPem = installFlags.StringLong("certpem", "", "string of the certificate in pem format") - cfg.install.hostAndPort = installFlags.StringLong("apchost", "", "hostname:port of the apc ups to install the certificate on") + cfg.install.hostname = installFlags.StringLong("hostname", "", "hostname of the apc ups to install the certificate on") + cfg.install.sshport = installFlags.IntLong("sshport", 22, "apc ups ssh port number") cfg.install.fingerprint = installFlags.StringLong("fingerprint", "", "the SHA256 fingerprint value of the ups' ssh server") cfg.install.username = installFlags.StringLong("username", "", "username to login to the apc ups") cfg.install.password = installFlags.StringLong("password", "", "password to login to the apc ups") cfg.install.restartWebUI = installFlags.BoolLong("restartwebui", "some devices may need a webui restart to begin using the new cert, enabling this option sends the restart command after the p15 is installed") + cfg.install.webUISSLPort = installFlags.IntLong("sslport", 443, "apc ups ssl webui port number") + cfg.install.skipVerify = installFlags.BoolLong("skipverify", "the tool will try to connect to the UPS web UI to verify install success; this flag disables that check") cfg.install.insecureCipher = installFlags.BoolLong("insecurecipher", "allows the use of insecure ssh ciphers (NOT recommended)") installCmd := &ff.Command{ Name: "install", - Usage: "apc-p15-tool install --keyfile key.pem --certfile cert.pem --apchost example.com:22 --fingerprint 123abc --username apc --password test", + Usage: "apc-p15-tool install --keyfile key.pem --certfile cert.pem --hostname example.com --fingerprint 123abc --username apc --password test", ShortHelp: "install the specified key and cert pem files on an apc ups (they will be converted to a comaptible p15 file)", Flags: installFlags, Exec: app.cmdInstall, diff --git a/pkg/app/pem_to_p15.go b/pkg/app/pem_to_p15.go index e376bc6..f6a6094 100644 --- a/pkg/app/pem_to_p15.go +++ b/pkg/app/pem_to_p15.go @@ -2,14 +2,52 @@ package app import ( "apc-p15-tool/pkg/pkcs15" + "crypto/x509" + "encoding/asn1" "fmt" + "slices" + "time" ) -// pemToAPCP15 reads the specified pem files and returns the apc p15 files (both a -// p15 file with just the private key, and also a p15 file with both the private key -// and certificate). The key+cert file includes the required APC header, prepended. +// list of keys supported by the NMC2 +var nmc2SupportedKeyTypes = []pkcs15.KeyType{ + pkcs15.KeyTypeRSA1024, + pkcs15.KeyTypeRSA2048, + pkcs15.KeyTypeRSA3072, // officially not supported but works +} + +// known good signing algorithms +var knownSupportedNMC2SigningAlgs = []x509.SignatureAlgorithm{ + x509.SHA256WithRSA, +} + +var knownSupportedNMC3SigningAlgs = append(knownSupportedNMC2SigningAlgs, []x509.SignatureAlgorithm{ + x509.ECDSAWithSHA384, +}...) + +// known supported cert extensions +var knownSupportedCriticalOIDs = []asn1.ObjectIdentifier{ + {2, 5, 29, 15}, // keyUsage + {2, 5, 29, 19}, // basicConstraints + {2, 5, 29, 17}, // subjectAltName +} + +var knownSupportedOIDs = append(knownSupportedCriticalOIDs, []asn1.ObjectIdentifier{ + {2, 5, 29, 37}, // extKeyUsage + {2, 5, 29, 14}, // subjectKeyIdentifier + {2, 5, 29, 35}, // authorityKeyIdentifier + {1, 3, 6, 1, 5, 5, 7, 1, 1}, // authorityInfoAccess + {2, 5, 29, 32}, // certificatePolicies + {1, 3, 6, 1, 4, 1, 11129, 2, 4, 2}, // googleSignedCertificateTimestamp + {2, 5, 29, 31}, // cRLDistributionPoints +}...) + +// pemToAPCP15 reads the specified pem files and returns the apc p15 file(s). If the +// key type of the key is not supported by NMC2, the combined key+cert file is not +// generated and nil is returned instead for that file. If the key IS supported by +// NMC2, the key+cert file is generated and the proper header is prepended. func (app *app) pemToAPCP15(keyPem, certPem []byte, parentCmdName string) (keyFile []byte, apcKeyCertFile []byte, err error) { - app.stdLogger.Printf("%s: making apc p15 file from pem", parentCmdName) + app.stdLogger.Printf("%s: making apc p15 file(s) content from pem", parentCmdName) // make p15 struct p15, err := pkcs15.ParsePEMToPKCS15(keyPem, certPem) @@ -17,24 +55,116 @@ func (app *app) pemToAPCP15(keyPem, certPem []byte, parentCmdName string) (keyFi return nil, nil, fmt.Errorf("%s: failed to parse pem files (%w)", parentCmdName, err) } - app.stdLogger.Printf("%s: successfully loaded pem files", parentCmdName) + app.stdLogger.Printf("%s: successfully parsed pem files", parentCmdName) - // make file bytes - keyCertFile, keyFile, err := p15.ToP15Files() + // make key file (always) + keyFile, err = p15.ToP15Key() if err != nil { - return nil, nil, fmt.Errorf("%s: failed to make p15 file (%w)", parentCmdName, err) + return nil, nil, fmt.Errorf("%s: failed to make p15 key file (%w)", parentCmdName, err) } - // make header for file bytes - apcHeader, err := makeFileHeader(keyCertFile) - if err != nil { - return nil, nil, fmt.Errorf("%s: failed to make p15 file header (%w)", parentCmdName, err) + app.stdLogger.Printf("%s: successfully generated p15 key file content", parentCmdName) + + // check key type for compat with NMC2 + nmc2KeyType := false + if slices.Contains(nmc2SupportedKeyTypes, p15.KeyType()) { + nmc2KeyType = true + + app.stdLogger.Printf("%s: key type is supported by NMC2, generating p15 key+cert file content...", parentCmdName) + + // make file bytes + keyCertFile, err := p15.ToP15KeyCert() + if err != nil { + return nil, nil, fmt.Errorf("%s: failed to make p15 key+cert file content (%w)", parentCmdName, err) + } + + // make header for file bytes + apcHeader, err := makeFileHeader(keyCertFile) + if err != nil { + return nil, nil, fmt.Errorf("%s: failed to make p15 key+cert file header (%w)", parentCmdName, err) + } + + // combine header with file + apcKeyCertFile = append(apcHeader, keyCertFile...) } - // combine header with file - apcKeyCertFile = append(apcHeader, keyCertFile...) + // check various parts of cert and log compatibility warnings + warned := false - app.stdLogger.Printf("%s: apc p15 file data succesfully generated", parentCmdName) + // key not supported for NMC2 + if !nmc2KeyType { + app.stdLogger.Printf("WARNING: %s: key type is %s and is not supported by NMC2.", parentCmdName, p15.KeyType().String()) + warned = true + } + + // signature algorithm (see: https://github.com/gregtwallace/apc-p15-tool/issues/18) + if !nmc2KeyType { + // definitely not for NMC2 + if !slices.Contains(knownSupportedNMC3SigningAlgs, p15.Cert.SignatureAlgorithm) { + app.stdLogger.Printf("WARNING: %s: Certificate signing algorithm is %s and it is not known if NMC3 supports this algorithm.", parentCmdName, p15.Cert.SignatureAlgorithm.String()) + warned = true + } + } else { + // could be for either NMC2 or NMC3 + if !slices.Contains(knownSupportedNMC2SigningAlgs, p15.Cert.SignatureAlgorithm) { + if !slices.Contains(knownSupportedNMC3SigningAlgs, p15.Cert.SignatureAlgorithm) { + // not in NMC2 or NMC3 list + app.stdLogger.Printf("WARNING: %s: Certificate signing algorithm is %s and is not supported by NMC2. It is also not known if NMC3 supports this algorithm.", parentCmdName, p15.Cert.SignatureAlgorithm.String()) + } else { + // not in NMC2 list, but is in NMC3 list + app.stdLogger.Printf("WARNING: %s: Certificate signing algorithm is %s and it does not support NMC2.", parentCmdName, p15.Cert.SignatureAlgorithm.String()) + } + warned = true + } + } + + // check validity dates + if time.Now().Before(p15.Cert.NotBefore) { + app.stdLogger.Printf("WARNING: %s: Current time (%s) is before certificate's NotBefore time (%s).", + parentCmdName, time.Now().Format(timeLoggingFormat), p15.Cert.NotBefore.Format(timeLoggingFormat)) + warned = true + } + + if time.Now().After(p15.Cert.NotAfter) { + app.stdLogger.Printf("WARNING: %s: Current time (%s) is after certificate's NotAfter time (%s).", + parentCmdName, time.Now().Format(timeLoggingFormat), p15.Cert.NotAfter.Format(timeLoggingFormat)) + warned = true + } + + // check extensions against known working extensions + for _, extension := range p15.Cert.Extensions { + // critical or not? + okOIDs := knownSupportedCriticalOIDs + criticalLogMsg := "Critical " + if !extension.Critical { + okOIDs = knownSupportedOIDs + criticalLogMsg = "" + } + + // validate OIDs + ok := false + for _, okOID := range okOIDs { + if okOID.Equal(extension.Id) { + ok = true + break + } + } + + if !ok { + app.stdLogger.Printf("WARNING: %s: %sExtension %s may not be supported by NMC.", parentCmdName, criticalLogMsg, extension.Id.String()) + } + } + + // log a message about possible failure + if warned { + app.stdLogger.Printf("WARNING: %s: Possible certificate compatibility issues were detected. If the resulting p15 file "+ + "does not work with your NMC (e.g., a self-signed certificate is regenerated after you try to install the p15), "+ + "modify your certificate to resolve the warnings and try again.", parentCmdName) + } + + // end compatibility warnings + + app.stdLogger.Printf("%s: apc p15 file(s) data succesfully generated", parentCmdName) return keyFile, apcKeyCertFile, nil } diff --git a/pkg/pkcs15/encrypted_envelope.go b/pkg/pkcs15/encrypted_envelope.go index cead3c8..a6dc525 100644 --- a/pkg/pkcs15/encrypted_envelope.go +++ b/pkg/pkcs15/encrypted_envelope.go @@ -21,14 +21,19 @@ const ( apcKEKIterations = 5000 ) -// encryptedKeyEnvelope encrypts p15's rsa private key using the algorithms and -// params expected in the APC file. Salt values are always random. -func (p15 *pkcs15KeyCert) encryptedKeyEnvelope() ([]byte, error) { +// encryptedKeyEnvelope encrypts p15's private key using the algorithms and +// params expected in the APC file. +func (p15 *pkcs15KeyCert) computeEncryptedKeyEnvelope() error { + // if computation already performed, this is a no-op (keep existing envelope) + if len(p15.envelopedPrivateKey) > 0 { + return nil + } + // calculate values for the object kekSalt := make([]byte, 8) _, err := rand.Read(kekSalt) if err != nil { - return nil, err + return err } // kek hash alg @@ -42,7 +47,7 @@ func (p15 *pkcs15KeyCert) encryptedKeyEnvelope() ([]byte, error) { // make DES cipher from KEK for CEK cekDesCipher, err := des.NewTripleDESCipher(kek) if err != nil { - return nil, err + return err } // cek (16 bytes for authEnc128) -- see: rfc3211 @@ -50,7 +55,7 @@ func (p15 *pkcs15KeyCert) encryptedKeyEnvelope() ([]byte, error) { cek := make([]byte, cekLen) _, err = rand.Read(cek) if err != nil { - return nil, err + return err } // LEN + Check Val [3] @@ -71,7 +76,7 @@ func (p15 *pkcs15KeyCert) encryptedKeyEnvelope() ([]byte, error) { cekPadding := make([]byte, cekPadLen) _, err = rand.Read(cekPadding) if err != nil { - return nil, err + return err } wrappedCEK = append(wrappedCEK, cekPadding...) @@ -80,7 +85,7 @@ func (p15 *pkcs15KeyCert) encryptedKeyEnvelope() ([]byte, error) { cekEncryptSalt := make([]byte, 8) _, err = rand.Read(cekEncryptSalt) if err != nil { - return nil, err + return err } cekEncrypter := cipher.NewCBCEncrypter(cekDesCipher, cekEncryptSalt) @@ -94,13 +99,13 @@ func (p15 *pkcs15KeyCert) encryptedKeyEnvelope() ([]byte, error) { contentEncSalt := make([]byte, 8) _, err = rand.Read(contentEncSalt) if err != nil { - return nil, err + return err } contentEncryptKey := pbkdf2.Key(cek, []byte("encryption"), 1, 24, sha1.New) contentDesCipher, err := des.NewTripleDESCipher(contentEncryptKey) if err != nil { - return nil, err + return err } // envelope content (that will be encrypted) @@ -151,7 +156,7 @@ func (p15 *pkcs15KeyCert) encryptedKeyEnvelope() ([]byte, error) { // make MAC _, err = macHasher.Write(hashMe) if err != nil { - return nil, err + return err } mac := macHasher.Sum(nil) @@ -218,5 +223,7 @@ func (p15 *pkcs15KeyCert) encryptedKeyEnvelope() ([]byte, error) { finalEnv = append(finalEnv, envelope[i]...) } - return finalEnv, nil + // set p15 struct envelope + p15.envelopedPrivateKey = finalEnv + return nil } diff --git a/pkg/pkcs15/keyid.go b/pkg/pkcs15/keyid.go index 68a3051..cac7301 100644 --- a/pkg/pkcs15/keyid.go +++ b/pkg/pkcs15/keyid.go @@ -2,6 +2,7 @@ package pkcs15 import ( "apc-p15-tool/pkg/tools/asn1obj" + "crypto/ecdsa" "crypto/rsa" "crypto/sha1" "encoding/binary" @@ -14,7 +15,7 @@ func (p15 *pkcs15KeyCert) keyId() []byte { // SHA-1 Hash hasher := sha1.New() - _, err := hasher.Write(p15.cert.RawSubjectPublicKeyInfo) + _, err := hasher.Write(p15.Cert.RawSubjectPublicKeyInfo) if err != nil { panic(err) } @@ -45,9 +46,9 @@ func (p15 *pkcs15KeyCert) keyIdInt3() []byte { // object to hash hashObj := asn1obj.Sequence([][]byte{ // issuerDistinguishedName - p15.cert.RawIssuer, + p15.Cert.RawIssuer, // serialNumber - asn1obj.Integer(p15.cert.SerialNumber), + asn1obj.Integer(p15.Cert.SerialNumber), }) // SHA-1 Hash @@ -73,7 +74,7 @@ func (p15 *pkcs15KeyCert) keyIdInt6() []byte { // SHA-1 Hash hasher := sha1.New() - _, err := hasher.Write(p15.cert.RawIssuer) + _, err := hasher.Write(p15.Cert.RawIssuer) if err != nil { panic(err) } @@ -94,7 +95,7 @@ func (p15 *pkcs15KeyCert) keyIdInt7() []byte { // SHA-1 Hash hasher := sha1.New() - _, err := hasher.Write(p15.cert.RawSubject) + _, err := hasher.Write(p15.Cert.RawSubject) if err != nil { panic(err) } @@ -119,9 +120,13 @@ func (p15 *pkcs15KeyCert) keyIdInt8() []byte { nBytes := privKey.N.Bytes() keyIdVal = nBytes[len(nBytes)-8:] + case *ecdsa.PrivateKey: + // don't use this key id, leave empty + return nil + default: - // panic if non-RSA key - panic("key id 8 for non-rsa key is unexpected and unsupported") + // panic if unexpected key type + panic("key id 8 for key is unexpected and unsupported") } // object to return @@ -163,7 +168,7 @@ func (p15 *pkcs15KeyCert) keyIdInt9() []byte { // to be ~ 1 hour ish BEFORE the cert was even created. Key would also // obviously have to be created prior to the cert creation. time := make([]byte, 4) - binary.BigEndian.PutUint32(time, uint32(p15.cert.NotBefore.Unix())) + binary.BigEndian.PutUint32(time, uint32(p15.Cert.NotBefore.Unix())) publicKeyPacket = append(publicKeyPacket, time...) // the next part is key type specific @@ -181,33 +186,13 @@ func (p15 *pkcs15KeyCert) keyIdInt9() []byte { e := big.NewInt(int64(privKey.PublicKey.E)) publicKeyPacket = append(publicKeyPacket, bigIntToMpi(e)...) - // case *ecdsa.PrivateKey: - // // A one-octet number denoting the public-key algorithm of this key. - // // 19 - ECDSA public key algorithm (see rfc 6637 s. 5) - // publicKeyPacket = append(publicKeyPacket, uint8(19)) - - // // Algorithm-Specific Fields for ECDSA public keys (see rfc 6637 s. 11 table) - // // This is a length byte followed by the curve ID (length is the number of bytes the curve ID uses) - // switch privKey.Curve.Params().Name { - // case "P-256": - // // 1.2.840.10045.3.1.7 8 2A 86 48 CE 3D 03 01 07 NIST curve P-256 - // publicKeyPacket = append(publicKeyPacket, byte(8)) - // hex, _ := hex.DecodeString("2A8648CE3D030107") - // publicKeyPacket = append(publicKeyPacket, hex...) - - // case "P-384": - // // 1.3.132.0.34 5 2B 81 04 00 22 NIST curve P-384 - // publicKeyPacket = append(publicKeyPacket, byte(5)) - // hex, _ := hex.DecodeString("2B81040022") - // publicKeyPacket = append(publicKeyPacket, hex...) - - // default: - // panic(fmt.Sprintf("key id 9 for ecdsa key curve %s is unexpected and unsupported", privKey.Curve.Params().Name)) - // } + case *ecdsa.PrivateKey: + // don't use this key id, leave empty + return nil default: - // panic if non-RSA key - panic("key id 9 for non-rsa key is unexpected and unsupported") + // panic if unexpected key type + panic("key id 9 for key is unexpected and unsupported") } // Assemble the V4 byte array that will be hashed diff --git a/pkg/pkcs15/pem_decode.go b/pkg/pkcs15/pem_decode.go index 66a34b7..2d51837 100644 --- a/pkg/pkcs15/pem_decode.go +++ b/pkg/pkcs15/pem_decode.go @@ -2,6 +2,7 @@ package pkcs15 import ( "crypto" + "crypto/ecdsa" "crypto/rsa" "crypto/tls" "crypto/x509" @@ -9,21 +10,27 @@ import ( "errors" "fmt" "reflect" + "slices" ) var ( errPemKeyBadBlock = errors.New("pkcs15: pem key: failed to decode pem block") errPemKeyFailedToParse = errors.New("pkcs15: pem key: failed to parse key") - errPemKeyWrongBlockType = errors.New("pkcs15: pem key: unsupported pem block type (only pkcs1 and pkcs8 supported)") - errPemKeyWrongType = errors.New("pkcs15: pem key: unsupported key type (only rsa 1,024, 2,048, and 3,072 supported)") + errPemKeyWrongBlockType = errors.New("pkcs15: pem key: unsupported pem block type") + errKeyWrongType = errors.New("pkcs15: pem key: unsupported key type") errPemCertBadBlock = errors.New("pkcs15: pem cert: failed to decode pem block") errPemCertFailedToParse = errors.New("pkcs15: pem cert: failed to parse cert") ) +var ( + supportedRSASizes = []int{1024, 2048, 3072, 4096} + supportedECDSACurves = []string{"P-256", "P-384", "P-521"} +) + // pemKeyDecode attempts to decode a pem encoded byte slice and then attempts -// to parse an RSA private key from the decoded pem block. an error is returned -// if any of these steps fail OR if the key is not RSA and of bitlen 1,024 or 2,048 +// to parse a private key from the decoded pem block. an error is returned +// if any of these steps fail OR if the key is not supported. func pemKeyDecode(keyPem []byte) (crypto.PrivateKey, error) { // decode pemBlock, _ := pem.Decode([]byte(keyPem)) @@ -47,28 +54,27 @@ func pemKeyDecode(keyPem []byte) (crypto.PrivateKey, error) { return nil, fmt.Errorf("pkcs15: pem key: failed sanity check (%s)", err) } - // verify proper bitlen - if rsaKey.N.BitLen() != 1024 && rsaKey.N.BitLen() != 2048 && rsaKey.N.BitLen() != 3072 { - return nil, errPemKeyWrongType + // verify supported rsa bitlen + if !slices.Contains(supportedRSASizes, rsaKey.N.BitLen()) { + return nil, errKeyWrongType } // good to go privateKey = rsaKey - // case "EC PRIVATE KEY": // SEC1, ASN.1 - // var ecdKey *ecdsa.PrivateKey - // ecdKey, err := x509.ParseECPrivateKey(pemBlock.Bytes) - // if err != nil { - // return nil, errPemKeyFailedToParse - // } + case "EC PRIVATE KEY": // SEC1, ASN.1 + ecdKey, err := x509.ParseECPrivateKey(pemBlock.Bytes) + if err != nil { + return nil, errPemKeyFailedToParse + } - // // verify acceptable curve name - // if ecdKey.Curve.Params().Name != "P-256" && ecdKey.Curve.Params().Name != "P-384" { - // return nil, errPemKeyWrongType - // } + // verify supported curve name + if !slices.Contains(supportedECDSACurves, ecdKey.Curve.Params().Name) { + return nil, errKeyWrongType + } - // // good to go - // privateKey = ecdKey + // good to go + privateKey = ecdKey case "PRIVATE KEY": // PKCS8 pkcs8Key, err := x509.ParsePKCS8PrivateKey(pemBlock.Bytes) @@ -84,25 +90,25 @@ func pemKeyDecode(keyPem []byte) (crypto.PrivateKey, error) { return nil, fmt.Errorf("pkcs15: pem key: failed sanity check (%s)", err) } - // verify proper bitlen - if pkcs8Key.N.BitLen() != 1024 && pkcs8Key.N.BitLen() != 2048 && pkcs8Key.N.BitLen() != 3072 { - return nil, errPemKeyWrongType + // verify supported rsa bitlen + if !slices.Contains(supportedRSASizes, pkcs8Key.N.BitLen()) { + return nil, errKeyWrongType } // good to go privateKey = pkcs8Key - // case *ecdsa.PrivateKey: - // // verify acceptable curve name - // if pkcs8Key.Curve.Params().Name != "P-256" && pkcs8Key.Curve.Params().Name != "P-384" { - // return nil, errPemKeyWrongType - // } + case *ecdsa.PrivateKey: + // verify supported curve name + if !slices.Contains(supportedECDSACurves, pkcs8Key.Curve.Params().Name) { + return nil, errKeyWrongType + } - // // good to go - // privateKey = pkcs8Key + // good to go + privateKey = pkcs8Key default: - return nil, errPemKeyWrongType + return nil, errKeyWrongType } default: diff --git a/pkg/pkcs15/pem_parse.go b/pkg/pkcs15/pem_parse.go index 2cd1fea..9df0ecd 100644 --- a/pkg/pkcs15/pem_parse.go +++ b/pkg/pkcs15/pem_parse.go @@ -2,14 +2,94 @@ package pkcs15 import ( "crypto" + "crypto/ecdsa" + "crypto/rsa" "crypto/x509" ) // pkcs15KeyCert holds the data for a key and certificate pair; it provides // various methods to transform pkcs15 data type pkcs15KeyCert struct { + Cert *x509.Certificate key crypto.PrivateKey - cert *x509.Certificate + // store the encrypted enveloped Private Key for re-use + envelopedPrivateKey []byte +} + +// KeyType is used by consumers to check for compatibility +type KeyType int + +const ( + KeyTypeRSA1024 KeyType = iota + KeyTypeRSA2048 + KeyTypeRSA3072 + KeyTypeRSA4096 + + KeyTypeECP256 + KeyTypeECP384 + KeyTypeECP521 + + KeyTypeUnknown +) + +// String returns the private key type in a log friendly string format. +func (keyType KeyType) String() string { + switch keyType { + case KeyTypeRSA1024: + return "RSA 1024-bit" + case KeyTypeRSA2048: + return "RSA 2048-bit" + case KeyTypeRSA3072: + return "RSA 3072-bit" + case KeyTypeRSA4096: + return "RSA 4096-bit" + + case KeyTypeECP256: + return "ECDSA P-256" + case KeyTypeECP384: + return "ECDSA P-384" + case KeyTypeECP521: + return "ECDSA P-521" + + default: + } + + return "unknown key type" +} + +// KeyType returns the private key type +func (p15 *pkcs15KeyCert) KeyType() KeyType { + switch pKey := p15.key.(type) { + case *rsa.PrivateKey: + switch pKey.N.BitLen() { + case 1024: + return KeyTypeRSA1024 + case 2048: + return KeyTypeRSA2048 + case 3072: + return KeyTypeRSA3072 + case 4096: + return KeyTypeRSA4096 + + default: + } + + case *ecdsa.PrivateKey: + switch pKey.Curve.Params().Name { + case "P-256": + return KeyTypeECP256 + case "P-384": + return KeyTypeECP384 + case "P-521": + return KeyTypeECP521 + + default: + } + + default: + } + + return KeyTypeUnknown } // ParsePEMToPKCS15 parses the provide pem files to a pkcs15 struct; it also does some @@ -27,9 +107,16 @@ func ParsePEMToPKCS15(keyPem, certPem []byte) (*pkcs15KeyCert, error) { return nil, err } + // create p15 struct p15 := &pkcs15KeyCert{ key: key, - cert: cert, + Cert: cert, + } + + // pre-calculate encrypted envelope + err = p15.computeEncryptedKeyEnvelope() + if err != nil { + return nil, err } return p15, nil diff --git a/pkg/pkcs15/pem_to_p15.go b/pkg/pkcs15/pem_to_p15.go index c213ddb..a68aba2 100644 --- a/pkg/pkcs15/pem_to_p15.go +++ b/pkg/pkcs15/pem_to_p15.go @@ -2,8 +2,10 @@ package pkcs15 import ( "apc-p15-tool/pkg/tools/asn1obj" + "crypto/ecdsa" "crypto/rsa" "encoding/asn1" + "fmt" "math/big" ) @@ -13,39 +15,87 @@ const ( // toP15KeyCert creates a P15 file with both the private key and certificate, mirroring the // final p15 file an APC UPS expects (though without the header) -func (p15 *pkcs15KeyCert) toP15KeyCert(keyEnvelope []byte) (keyCert []byte, err error) { - // private key object - privateKey := asn1obj.Sequence([][]byte{ - // commonObjectAttributes - Label - asn1obj.Sequence([][]byte{ - asn1obj.UTF8String(apcKeyLabel), - }), - // CommonKeyAttributes - asn1obj.Sequence([][]byte{ - // CommonKeyAttributes - iD - uses keyId that is SHA1( SubjectPublicKeyInfo SEQUENCE ) - asn1obj.OctetString(p15.keyId()), - // CommonKeyAttributes - usage (trailing 0s will drop) - asn1obj.BitString([]byte{byte(0b11100010)}), - // CommonKeyAttributes - accessFlags (trailing 0s will drop) - asn1obj.BitString([]byte{byte(0b10110000)}), - // CommonKeyAttributes - startDate - asn1obj.GeneralizedTime(p15.cert.NotBefore), - // CommonKeyAttributes - [0] endDate - asn1obj.GeneralizedTimeExplicitValue(0, p15.cert.NotAfter), - }), - // ObjectValue - indirect-protected - asn1obj.ExplicitCompound(1, [][]byte{ +func (p15 *pkcs15KeyCert) ToP15KeyCert() (keyCert []byte, err error) { + // encrypted envelope is required + err = p15.computeEncryptedKeyEnvelope() + if err != nil { + return nil, err + } + + // create private key object + var privKeyObj []byte + + switch p15.key.(type) { + case *rsa.PrivateKey: + // private key object + privKeyObj = asn1obj.Sequence([][]byte{ - // AuthEnvelopedData Type ([4]) - asn1obj.ExplicitCompound(4, [][]byte{ - keyEnvelope, + // commonObjectAttributes - Label + asn1obj.Sequence([][]byte{ + asn1obj.UTF8String(apcKeyLabel), }), - }), - }), - }) + // CommonKeyAttributes + asn1obj.Sequence([][]byte{ + // CommonKeyAttributes - iD - uses keyId that is SHA1( SubjectPublicKeyInfo SEQUENCE ) + asn1obj.OctetString(p15.keyId()), + // CommonKeyAttributes - usage (trailing 0s will drop) + asn1obj.BitString([]byte{byte(0b11100010)}), + // CommonKeyAttributes - accessFlags (trailing 0s will drop) + asn1obj.BitString([]byte{byte(0b10110000)}), + // CommonKeyAttributes - startDate + asn1obj.GeneralizedTime(p15.Cert.NotBefore), + // CommonKeyAttributes - [0] endDate + asn1obj.GeneralizedTimeExplicitValue(0, p15.Cert.NotAfter), + }), + // ObjectValue - indirect-protected + asn1obj.ExplicitCompound(1, [][]byte{ + asn1obj.Sequence([][]byte{ + // AuthEnvelopedData Type ([4]) + asn1obj.ExplicitCompound(4, [][]byte{ + p15.envelopedPrivateKey, + }), + }), + }), + }) + + case *ecdsa.PrivateKey: + privKeyObj = + asn1obj.ExplicitCompound(0, [][]byte{ + // commonObjectAttributes - Label + asn1obj.Sequence([][]byte{ + asn1obj.UTF8String(apcKeyLabel), + }), + // CommonKeyAttributes + asn1obj.Sequence([][]byte{ + // CommonKeyAttributes - iD - uses keyId that is SHA1( SubjectPublicKeyInfo SEQUENCE ) + asn1obj.OctetString(p15.keyId()), + // CommonKeyAttributes - usage (trailing 0s will drop) + asn1obj.BitString([]byte{byte(0b00100010)}), + // CommonKeyAttributes - accessFlags (trailing 0s will drop) + asn1obj.BitString([]byte{byte(0b10110000)}), + // CommonKeyAttributes - startDate + asn1obj.GeneralizedTime(p15.Cert.NotBefore), + // CommonKeyAttributes - [0] endDate + asn1obj.GeneralizedTimeExplicitValue(0, p15.Cert.NotAfter), + }), + // ObjectValue - indirect-protected + asn1obj.ExplicitCompound(1, [][]byte{ + asn1obj.Sequence([][]byte{ + // AuthEnvelopedData Type ([4]) + asn1obj.ExplicitCompound(4, [][]byte{ + p15.envelopedPrivateKey, + }), + }), + }), + }) + + default: + // bad key type + return nil, errKeyWrongType + } // cert object - cert := asn1obj.Sequence([][]byte{ + certObj := asn1obj.Sequence([][]byte{ // commonObjectAttributes - Label asn1obj.Sequence([][]byte{ asn1obj.UTF8String(apcKeyLabel), @@ -59,25 +109,26 @@ func (p15 *pkcs15KeyCert) toP15KeyCert(keyEnvelope []byte) (keyCert []byte, err p15.keyIdInt3(), p15.keyIdInt6(), p15.keyIdInt7(), + // 8 & 9 will return nil for EC keys (effectively omitting them) p15.keyIdInt8(), p15.keyIdInt9(), }), // CommonKeyAttributes - startDate - asn1obj.GeneralizedTime(p15.cert.NotBefore), + asn1obj.GeneralizedTime(p15.Cert.NotBefore), // CommonKeyAttributes - [4] endDate - asn1obj.GeneralizedTimeExplicitValue(4, p15.cert.NotAfter), + asn1obj.GeneralizedTimeExplicitValue(4, p15.Cert.NotAfter), }), // actual certificate itself asn1obj.ExplicitCompound(1, [][]byte{ asn1obj.Sequence([][]byte{ asn1obj.ExplicitCompound(0, [][]byte{ - p15.cert.Raw, + p15.Cert.Raw, }), }), }), }) - // build the file + // build the object // ContentInfo keyCert = asn1obj.Sequence([][]byte{ @@ -92,12 +143,12 @@ func (p15 *pkcs15KeyCert) toP15KeyCert(keyEnvelope []byte) (keyCert []byte, err asn1obj.Sequence([][]byte{ asn1obj.ExplicitCompound(0, [][]byte{ asn1obj.ExplicitCompound(0, [][]byte{ - privateKey, + privKeyObj, }), }), asn1obj.ExplicitCompound(4, [][]byte{ asn1obj.ExplicitCompound(0, [][]byte{ - cert, + certObj, }), }), }), @@ -111,141 +162,212 @@ func (p15 *pkcs15KeyCert) toP15KeyCert(keyEnvelope []byte) (keyCert []byte, err // toP15Key creates a P15 file with just the private key, mirroring the p15 format // the APC tool uses when generating a new private key (Note: no header is used on // this file) -func (p15 *pkcs15KeyCert) toP15Key(keyEnvelope []byte) (key []byte, err error) { - // create public key object - var pubKeyObj []byte +func (p15 *pkcs15KeyCert) ToP15Key() (key []byte, err error) { + // encrypted envelope is required + err = p15.computeEncryptedKeyEnvelope() + if err != nil { + return nil, err + } + + // create private and public key objects + var pubKeyObj, privKeyObj []byte switch privKey := p15.key.(type) { case *rsa.PrivateKey: - pubKeyObj = asn1obj.ExplicitCompound(1, [][]byte{ + // private key object (slightly different than the key+cert format) + privKeyObj = asn1obj.Sequence([][]byte{ + // commonObjectAttributes - Label + asn1obj.Sequence([][]byte{ + asn1obj.UTF8String(apcKeyLabel), + }), + // CommonKeyAttributes + asn1obj.Sequence([][]byte{ + // CommonKeyAttributes - iD - uses keyId that is SHA1( SubjectPublicKeyInfo SEQUENCE ) + asn1obj.OctetString(p15.keyId()), + // CommonKeyAttributes - usage (trailing 0s will drop) + asn1obj.BitString([]byte{byte(0b11100010)}), + // CommonKeyAttributes - accessFlags (trailing 0s will drop) + asn1obj.BitString([]byte{byte(0b10110000)}), + }), + + // Key IDs asn1obj.ExplicitCompound(0, [][]byte{ - asn1obj.ExplicitCompound(1, [][]byte{ - asn1obj.Sequence([][]byte{ - asn1obj.ObjectIdentifier(asn1obj.OIDrsaEncryptionPKCS1), - asn1.NullBytes, + asn1obj.Sequence([][]byte{ + asn1obj.ExplicitCompound(0, [][]byte{ + p15.keyIdInt2(), + p15.keyIdInt8(), + p15.keyIdInt9(), }), - // RSAPublicKey SubjectPublicKeyInfo - asn1obj.BitString( - asn1obj.Sequence([][]byte{ - asn1obj.Integer(privKey.PublicKey.N), - asn1obj.Integer(big.NewInt(int64(privKey.PublicKey.E))), - }), - ), }), }), - // not 100% certain but appears to be rsa key byte len - asn1obj.Integer(big.NewInt(int64(privKey.PublicKey.N.BitLen() / 8))), - }), - }) + + // ObjectValue - indirect-protected + asn1obj.ExplicitCompound(1, [][]byte{ + asn1obj.Sequence([][]byte{ + // AuthEnvelopedData Type ([4]) + asn1obj.ExplicitCompound(4, [][]byte{ + p15.envelopedPrivateKey, + }), + }), + }), + }) + + // pub key stub + pubKeyObj = + asn1obj.Sequence([][]byte{ + // commonObjectAttributes - Label + asn1obj.Sequence([][]byte{ + asn1obj.UTF8String(apcKeyLabel), + }), + // CommonKeyAttributes + asn1obj.Sequence([][]byte{ + asn1obj.OctetString(p15.keyId()), + asn1obj.BitString([]byte{byte(0b10000010)}), + asn1obj.BitString([]byte{byte(0b01000000)}), + }), + + asn1obj.ExplicitCompound(1, [][]byte{ + asn1obj.Sequence([][]byte{ + asn1obj.ExplicitCompound(0, [][]byte{ + asn1obj.ExplicitCompound(1, [][]byte{ + asn1obj.Sequence([][]byte{ + asn1obj.ObjectIdentifier(asn1obj.OIDrsaEncryptionPKCS1), + asn1.NullBytes, + }), + // RSAPublicKey SubjectPublicKeyInfo + asn1obj.BitString( + asn1obj.Sequence([][]byte{ + asn1obj.Integer(privKey.PublicKey.N), + asn1obj.Integer(big.NewInt(int64(privKey.PublicKey.E))), + }), + ), + }), + }), + // not 100% certain but appears to be rsa key byte len + asn1obj.Integer(big.NewInt(int64(privKey.PublicKey.N.BitLen() / 8))), + }), + }), + }) + + case *ecdsa.PrivateKey: + // private key object (slightly different than the key+cert format) + privKeyObj = + asn1obj.ExplicitCompound(0, [][]byte{ + // commonObjectAttributes - Label + asn1obj.Sequence([][]byte{ + asn1obj.UTF8String(apcKeyLabel), + }), + // CommonKeyAttributes + asn1obj.Sequence([][]byte{ + // CommonKeyAttributes - iD - uses keyId that is SHA1( SubjectPublicKeyInfo SEQUENCE ) + asn1obj.OctetString(p15.keyId()), + // CommonKeyAttributes - usage (trailing 0s will drop) + asn1obj.BitString([]byte{byte(0b00100010)}), + // CommonKeyAttributes - accessFlags (trailing 0s will drop) + asn1obj.BitString([]byte{byte(0b10110000)}), + }), + + // Key IDs + asn1obj.ExplicitCompound(0, [][]byte{ + asn1obj.Sequence([][]byte{ + asn1obj.ExplicitCompound(0, [][]byte{ + p15.keyIdInt2(), + }), + }), + }), + + // ObjectValue - indirect-protected + asn1obj.ExplicitCompound(1, [][]byte{ + asn1obj.Sequence([][]byte{ + // AuthEnvelopedData Type ([4]) + asn1obj.ExplicitCompound(4, [][]byte{ + p15.envelopedPrivateKey, + }), + }), + }), + }) + + // convert ec pub key to a form that provides a public key bytes function + ecdhKey, err := privKey.PublicKey.ECDH() + if err != nil { + return nil, fmt.Errorf("failed to parse ec public key (%s)", err) + } + + // select correct OID for curve + var curveOID asn1.ObjectIdentifier + switch privKey.Curve.Params().Name { + case "P-256": + curveOID = asn1obj.OIDprime256v1 + case "P-384": + curveOID = asn1obj.OIDsecp384r1 + case "P-521": + curveOID = asn1obj.OIDsecp521r1 + default: + // bad curve name + return nil, errKeyWrongType + } + + // pub key stub + pubKeyObj = + asn1obj.ExplicitCompound(0, [][]byte{ + // commonObjectAttributes - Label + asn1obj.Sequence([][]byte{ + asn1obj.UTF8String(apcKeyLabel), + }), + // CommonKeyAttributes + asn1obj.Sequence([][]byte{ + asn1obj.OctetString(p15.keyId()), + asn1obj.BitString([]byte{byte(0b00000010)}), + asn1obj.BitString([]byte{byte(0b01000000)}), + }), + + asn1obj.ExplicitCompound(1, [][]byte{ + asn1obj.Sequence([][]byte{ + asn1obj.ExplicitCompound(0, [][]byte{ + asn1obj.Sequence([][]byte{ + asn1obj.Sequence([][]byte{ + asn1obj.ObjectIdentifier(asn1obj.OIDecPublicKey), + asn1obj.ObjectIdentifier(curveOID), + }), + asn1obj.BitString(ecdhKey.Bytes()), + }), + }), + }), + }), + }) default: - // panic if non-RSA key - panic("p15 key file for non-rsa key is unexpected and unsupported") + // bad key type + return nil, errKeyWrongType } - // private key object (slightly different than the key+cert format) - privateKey := asn1obj.Sequence([][]byte{ - // commonObjectAttributes - Label + // assemble complete object + key = asn1obj.Sequence([][]byte{ - asn1obj.UTF8String(apcKeyLabel), - }), - // CommonKeyAttributes - asn1obj.Sequence([][]byte{ - // CommonKeyAttributes - iD - uses keyId that is SHA1( SubjectPublicKeyInfo SEQUENCE ) - asn1obj.OctetString(p15.keyId()), - // CommonKeyAttributes - usage (trailing 0s will drop) - asn1obj.BitString([]byte{byte(0b11100010)}), - // CommonKeyAttributes - accessFlags (trailing 0s will drop) - asn1obj.BitString([]byte{byte(0b10110000)}), - }), - - // - asn1obj.ExplicitCompound(0, [][]byte{ - asn1obj.Sequence([][]byte{ - asn1obj.ExplicitCompound(0, [][]byte{ - p15.keyIdInt2(), - p15.keyIdInt8(), - p15.keyIdInt9(), - }), - }), - }), - - // ObjectValue - indirect-protected - asn1obj.ExplicitCompound(1, [][]byte{ - asn1obj.Sequence([][]byte{ - // AuthEnvelopedData Type ([4]) - asn1obj.ExplicitCompound(4, [][]byte{ - keyEnvelope, - }), - }), - }), - }) - - // ContentInfo - key = asn1obj.Sequence([][]byte{ - - // contentType: OID: 1.2.840.113549.1.15.3.1 pkcs15content (PKCS #15 content type) - asn1obj.ObjectIdentifier(asn1obj.OIDPkscs15Content), - - // content - asn1obj.ExplicitCompound(0, [][]byte{ - asn1obj.Sequence([][]byte{ - asn1obj.Integer(big.NewInt(0)), + // contentType: OID: 1.2.840.113549.1.15.3.1 pkcs15content (PKCS #15 content type) + asn1obj.ObjectIdentifier(asn1obj.OIDPkscs15Content), + // content + asn1obj.ExplicitCompound(0, [][]byte{ asn1obj.Sequence([][]byte{ - // [0] Private Key - asn1obj.ExplicitCompound(0, [][]byte{ + asn1obj.Integer(big.NewInt(0)), + asn1obj.Sequence([][]byte{ + // [0] Private Keys asn1obj.ExplicitCompound(0, [][]byte{ - privateKey, + asn1obj.ExplicitCompound(0, [][]byte{ + privKeyObj, + }), }), - }), - // [1] Public Key - asn1obj.ExplicitCompound(1, [][]byte{ - asn1obj.ExplicitCompound(0, [][]byte{ - asn1obj.Sequence([][]byte{ - // commonObjectAttributes - Label - asn1obj.Sequence([][]byte{ - asn1obj.UTF8String(apcKeyLabel), - }), - // CommonKeyAttributes - asn1obj.Sequence([][]byte{ - asn1obj.OctetString(p15.keyId()), - asn1obj.BitString([]byte{byte(0b10000010)}), - asn1obj.BitString([]byte{byte(0b01000000)}), - }), - + // [1] Public Keys + asn1obj.ExplicitCompound(1, [][]byte{ + asn1obj.ExplicitCompound(0, [][]byte{ pubKeyObj, }), }), }), }), }), - }), - }) + }) return key, nil } - -// ToP15File turns the key and cert into a properly formatted and encoded -// p15 file -func (p15 *pkcs15KeyCert) ToP15Files() (keyCertFile []byte, keyFile []byte, err error) { - // rsa encrypted key in encrypted envelope (will be shared by both output files) - envelope, err := p15.encryptedKeyEnvelope() - if err != nil { - return nil, nil, err - } - - // key + cert file - keyCertFile, err = p15.toP15KeyCert(envelope) - if err != nil { - return nil, nil, err - } - - // key only file - keyFile, err = p15.toP15Key(envelope) - if err != nil { - return nil, nil, err - } - - return keyCertFile, keyFile, nil -} diff --git a/pkg/pkcs15/private_key.go b/pkg/pkcs15/private_key.go index 0321551..59b538e 100644 --- a/pkg/pkcs15/private_key.go +++ b/pkg/pkcs15/private_key.go @@ -2,6 +2,7 @@ package pkcs15 import ( "apc-p15-tool/pkg/tools/asn1obj" + "crypto/ecdsa" "crypto/rsa" ) @@ -27,15 +28,13 @@ func (p15 *pkcs15KeyCert) privateKeyObject() []byte { asn1obj.IntegerExplicitValue(7, privKey.Precomputed.Qinv), }) - // case *ecdsa.PrivateKey: - // // Only private piece is the integer D - // privKeyObj = asn1obj.Sequence([][]byte{ - // asn1obj.Integer(privKey.D), - // }) + case *ecdsa.PrivateKey: + // Only private piece is the integer D + privKeyObj = asn1obj.Integer(privKey.D) default: - // panic if non-RSA key - panic("private key object for non-rsa key is unexpected and unsupported") + // panic if unsupported key + panic("private key type is unexpected and unsupported") } return privKeyObj diff --git a/pkg/tools/asn1obj/oid.go b/pkg/tools/asn1obj/oid.go index 3975ded..70009c2 100644 --- a/pkg/tools/asn1obj/oid.go +++ b/pkg/tools/asn1obj/oid.go @@ -11,6 +11,10 @@ var ( OIDdesEDE3CBC = asn1.ObjectIdentifier{1, 2, 840, 113549, 3, 7} // des-EDE3-CBC (RSADSI encryptionAlgorithm) OIDpkcs7Data = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 7, 1} // data (PKCS #7) OIDauthEnc128 = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 16, 3, 15} // authEnc128 (S/MIME Algorithms) + OIDecPublicKey = asn1.ObjectIdentifier{1, 2, 840, 10045, 2, 1} // ecPublicKey (ANSI X9.62 public key type) + OIDprime256v1 = asn1.ObjectIdentifier{1, 2, 840, 10045, 3, 1, 7} // prime256v1 (ANSI X9.62 named elliptic curve) + OIDsecp384r1 = asn1.ObjectIdentifier{1, 3, 132, 0, 34} // secp384r1 (SECG (Certicom) named elliptic curve) + OIDsecp521r1 = asn1.ObjectIdentifier{1, 3, 132, 0, 35} // secp521r1 (SECG (Certicom) named elliptic curve) ) // ObjectIdentifier returns an ASN.1 OBJECT IDENTIFIER with the oidValue bytes