diff --git a/.github/workflows/build_releases.yml b/.github/workflows/build_releases.yml index d1a0d90..5b41dbe 100644 --- a/.github/workflows/build_releases.yml +++ b/.github/workflows/build_releases.yml @@ -8,11 +8,11 @@ on: env: GITHUB_REF: ${{ github.ref }} - GO_VERSION: '1.22.1' + GO_VERSION: '1.24.2' jobs: build-common: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Checkout Main Repo @@ -40,8 +40,10 @@ jobs: name: CHANGELOG.md path: ./CHANGELOG.md +### + build-linux-arm64: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04-arm steps: - name: Checkout Repo uses: actions/checkout@v4 @@ -50,12 +52,6 @@ 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: @@ -66,7 +62,6 @@ jobs: env: GOOS: linux GOARCH: arm64 - CC: aarch64-linux-gnu-gcc CGO_ENABLED: 0 - name: Save Compiled Binary @@ -90,7 +85,7 @@ jobs: path: ./apc-p15-install build-linux-amd64: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Checkout Backend Repo uses: actions/checkout@v4 @@ -171,9 +166,93 @@ jobs: name: apc-p15-install-windows-amd64 path: ./apc-p15-install.exe + build-darwin-arm64: + runs-on: macos-15 + 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: darwin + GOARCH: arm64 + CGO_ENABLED: 0 + + - name: Save Compiled Binary + uses: actions/upload-artifact@v4 + with: + name: apc-p15-tool-darwin-arm64 + path: ./apc-p15-tool + + - name: Build Install Only + run: go build -o ./apc-p15-install -v ./cmd/install_only + env: + GOOS: darwin + GOARCH: arm64 + CGO_ENABLED: 0 + + - name: Save Compiled Binary + uses: actions/upload-artifact@v4 + with: + name: apc-p15-install-darwin-arm64 + path: ./apc-p15-install + + build-darwin-amd64: + runs-on: macos-13 + 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: darwin + GOARCH: amd64 + CGO_ENABLED: 0 + + - name: Save Compiled Binary + uses: actions/upload-artifact@v4 + with: + name: apc-p15-tool-darwin-amd64 + path: ./apc-p15-tool + + - name: Build Install Only + run: go build -o ./apc-p15-install -v ./cmd/install_only + env: + GOOS: darwin + GOARCH: amd64 + CGO_ENABLED: 0 + + - name: Save Compiled Binary + uses: actions/upload-artifact@v4 + with: + name: apc-p15-install-darwin-amd64 + path: ./apc-p15-install + +### + release-file-linux-arm64: needs: [build-common, build-linux-arm64] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Make release directory @@ -217,7 +296,7 @@ jobs: release-file-linux-amd64: needs: [build-common, build-linux-amd64] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Make release directory @@ -261,7 +340,7 @@ jobs: release-file-windows-amd64: needs: [build-common, build-windows-amd64] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Make release directory @@ -302,3 +381,91 @@ jobs: with: name: apc-p15-tool_windows_amd64 path: ./release + + release-file-darwin-arm64: + needs: [build-common, build-darwin-arm64] + runs-on: ubuntu-24.04 + + steps: + - name: Make release directory + run: mkdir ./release + + - name: Download Tool Binary + uses: actions/download-artifact@v4 + with: + name: apc-p15-tool-darwin-arm64 + path: ./release + + - name: Download Install Binary + uses: actions/download-artifact@v4 + with: + name: apc-p15-install-darwin-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_darwin_arm64 + path: ./release + + release-file-darwin-amd64: + needs: [build-common, build-darwin-amd64] + runs-on: ubuntu-24.04 + + steps: + - name: Make release directory + run: mkdir ./release + + - name: Download Tool Binary + uses: actions/download-artifact@v4 + with: + name: apc-p15-tool-darwin-amd64 + path: ./release + + - name: Download Install Binary + uses: actions/download-artifact@v4 + with: + name: apc-p15-install-darwin-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_darwin_amd64 + path: ./release diff --git a/.gitignore b/.gitignore index 9e11ecb..6cd9c9f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # key/cert files *.p15 *.pem +*.b64 # ignore test_data folder /_test_data diff --git a/CHANGELOG.md b/CHANGELOG.md index ecfe84d..cc534a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,104 @@ # APC P15 Tool Changelog +## [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. + + +## [v0.5.2] - 2024-06-19 + +Minor tweak to the previous version. Add timeout for shell +commands that don't execute as expected. + + +## [v0.5.1] - 2024-06-18 + +Both NMC2 and NMC3 should now be fully supported. + +### Added +- Add proper NMC3 support. +- The `create` function now also generates a .p15 formatted key file. + The format of this file matches that of what is generated by the NMC + Security Wizard. +- Add additional b64 formatted output files when using the `--debug` + flag with `create`. These files can easily be pasted into an ASN1 + decoder for inspection (except for the header file, as the header is + not ASN1 encoded). + +### Fixed +- Fix `install` function for NMC3 on newer firmware version by + leveraging the native `ssl` command to install the key and cert, if + it is available. If not available, fallback to the 'old' way of + installing the SSL cert. +- Fix PowerShell build script in repo. Posted builds were not impacted + by this as the script is not used by the GitHub Action. + +### Changed +- Move APC SSH functions to a separate package and change how commands + are sent. In particular, leverage the interactive shell to send + commands and read back the result of those commands. +- Set output file permissions to `0600` instead of `0777`. +- Minor logging updates. +- Leverage `strings.EqualFold` as a more robust alternative to using + `strings.ToLower` for string comparisons. +- Update Go version to 1.22.4. +- Update readme to clarify tool's purpose, current state, and + compatibility. + +### Removed +N/A + + ## [v0.4.2] - 2024-03-29 Fix usage message. Thanks @k725. @@ -13,7 +112,7 @@ Update to Go 1.22.1, which includes some security fixes. ## [v0.4.0] - 2024-02-05 Add `--restartwebui` flag to issue a reboot command to the webui -after a new certificate is installed. This was not needed with +after a new certificate is installed. This was not needed with my NMC2, but I suspect some might need it to get the new certificate to actually load. @@ -22,7 +121,7 @@ to actually load. Add `--insecurecipher` flag to enable aes128-cbc and 3des-cbc for older devices/firmwares. These ciphers are considered insecure and -should be avoided. A better alternative is to update the device +should be avoided. A better alternative is to update the device firmware if possible. @@ -32,7 +131,7 @@ Add support for 1,024 bit RSA keys. These are not recommended! RSA 1024 is generally considered to not be completely secure anymore. Add `diffie-hellman-group-exchange-sha256` key exchange algorithm -which may be needed by some UPSes to connect via SSH to use the +which may be needed by some UPSes to connect via SSH to use the install command. diff --git a/README.md b/README.md index 10ea261..1d494ed 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,106 @@ # APC P15 Tool -A tool to create APC p15 formatted certificates from pem files, without -having to use APC's closed-source tool, APC generated keys, or other -proprietary tools (such as cryptlib). + +APC P15 Tool is a completely open source application designed to make +creating and installing SSL certificates on APC (Schneider Electric) +Network Management Cards (2 & 3) simple and easy to do. It is also +designed to simplify automation of the certificate management lifecycle. + +## Background + +When APC created the NMC2 (Network Management Card 2), they chose to use +the p15 file format for their SSL keys and certificates, which is a +relatively obscure file format. In addition to this, they designed the +device to require an APC specific header be prepended to the p15 file +or the file would be rejected by the device. Accordingly, they created +a proprietary tool (the `NMC Security Wizard CLI Utility`) to generate +the required format. + +Unfortunately, the proprietary tool has a number of shortcomings: +- It can be difficult to find the right version to use. APC has released + a number of versions (in both a CLI and GUI form). Not all of the + versions worked correctly (or at all). +- User provided private keys are not supported. Private keys must be + generated by the proprietary tool and are only outputted in the p15 + format. APC's proprietary tool is closed source and as such there is + no way to audit the key generation process. +- Since the generated keys are in the p15 format, they can't be loaded + easily into other management tools (such as Cert Warden + https://www.certwarden.com/), nor can CSRs be generated easily + outside of the proprietary tool. The proprietary tool is generally + required to generate the CSR. +- The CSR generation function in the proprietary tool is fairly rigid, + making customization (e.g., multiple DNS names) difficult, if not + impossible. +- After the user generates a key, generates a CSR, sends that CSR to + their CA, and receives a certificate back, they're still not done. + The tool must be used again to generate the final p15 file for the + NMC. +- To install the final file on the NMC, the user must use an SCP + program such as `pscp` to install the file, or the NMC's web UI. + +Due to all of this, others have tried to recreate the proprietary +functionality. The only implementations I have found rely on a closed +source library called `cryptlib`. This library has evolved over time +and more recent versions do not work for the NMC (it appears at some +point cryptlib switched from 3DES to AES and NMC does not support +AES within the p15 file). It was also near impossible to find an old +enough version of cryptlib that would work. Even if one gets this +working, it does not resolve the obscurity of a closed source +implementation and would continue to be subject to potential future +breakage as the cryptlib library continues to evolve. + +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 -This tool's create functionality is modeled from the APC NMCSecurityWizardCLI -aka `NMC Security Wizard CLI Utility`. The files it generates should be -comaptible with any UPS that accepts p15 files from that tool. Only RSA 1,024 -and 2,048 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. +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. -The install functionality is a custom creation of mine so it may or may not -work depending on your exact setup. My setup (and therefore the testing -setup) is: +### Key Types and Sizes + +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. + +### General Troubleshooting + +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 you can 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. +If you have trouble, your first step should be to update your NMC's firmware. +Many issues with this tool will be resolved simply by updating to the newest +firmware. -In particular, if you are experiencing `ssh: handshake failed:` please run -`ssh -vv myups.example.com` and include the `peer server KEXINIT proposal` -in your issue. For example: +If you have a problem after that, 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. + +In particular, 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 +exploitable. If this also does not work, please run `ssh -vv myups.example.com` +and include the `peer server KEXINIT proposal` in your issue. For example: ``` debug2: peer server KEXINIT proposal @@ -59,16 +135,18 @@ content. e.g. `./apc-p15-tool create --keyfile ./apckey.pem --certfile ./apccert.pem` -The command outputs ./apctool.p15 by default. This file can be -directly loaded on to an APC NMC2 (Network Management Card 2). +The command creates and outputs ./apctool.p15 and ./apctool.key.p15 by +default. These files are equivelant to the key and final p15 files +generated by APC's proprietary tool. ### Install -Install works similarly to create except it doesn't save the p15 file -to disk. It instead uploads the p15 file directly to the specified -remote host, via scp. +Install generates the necessary p15 file(s) but does NOT save them to +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 @@ -86,12 +164,12 @@ for passing the pem content from another application without having to save the pem files to disk. Putting all of this together, you can combine the install binary with -a tool like LeGo CertHub (https://www.legocerthub.com/) to call the +a tool like Cert Warden (https://www.certwarden.com/) to call the install binary, with environment variables, to directly upload new -certificates as they're issued by LeGo, without having to write a +certificates as they're issued by Cert Warden, without having to write a separate script. - + ## Thanks diff --git a/build.ps1 b/build.ps1 index 3fee1f4..0c8774b 100644 --- a/build.ps1 +++ b/build.ps1 @@ -11,7 +11,7 @@ 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/tool +go build -o $outDir/apc-p15-install-amd64.exe ./cmd/install_only # Linux x64 $env:GOARCH = "amd64" @@ -34,3 +34,25 @@ $env:GOARCH = "arm64" $env:GOOS = "linux" $env:CGO_ENABLED = 0 go build -o $outDir/apc-p15-install-arm64 ./cmd/install_only + +# Darwin (MacOS) amd64 +$env:GOARCH = "amd64" +$env:GOOS = "darwin" +$env:CGO_ENABLED = 0 +go build -o $outDir/apc-p15-tool-darwin-amd64 ./cmd/tool + +$env:GOARCH = "amd64" +$env:GOOS = "darwin" +$env:CGO_ENABLED = 0 +go build -o $outDir/apc-p15-install-darwin-amd64 ./cmd/install_only + +# Darwin (MacOS) arm64 +$env:GOARCH = "arm64" +$env:GOOS = "darwin" +$env:CGO_ENABLED = 0 +go build -o $outDir/apc-p15-tool-darwin-arm64 ./cmd/tool + +$env:GOARCH = "arm64" +$env:GOOS = "darwin" +$env:CGO_ENABLED = 0 +go build -o $outDir/apc-p15-install-darwin-arm64 ./cmd/install_only diff --git a/go.mod b/go.mod index 5d6ca7f..76eb3ca 100644 --- a/go.mod +++ b/go.mod @@ -1,19 +1,21 @@ module apc-p15-tool -go 1.22.1 +go 1.24.2 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.37.0 ) -require golang.org/x/sys v0.16.0 // indirect +require golang.org/x/sys v0.32.0 // indirect replace apc-p15-tool/cmd/install_only => /cmd/install_only replace apc-p15-tool/cmd/tool => /cmd/tool +replace apc-p15-tool/pkg/apcssh => /pkg/apcssh + replace apc-p15-tool/pkg/app => /pkg/app replace apc-p15-tool/pkg/pkcs15 => /pkg/pkcs15 diff --git a/go.sum b/go.sum index 96676f0..0b94b0b 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.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= +golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= 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/client.go b/pkg/apcssh/client.go new file mode 100644 index 0000000..4e7f1d3 --- /dev/null +++ b/pkg/apcssh/client.go @@ -0,0 +1,128 @@ +package apcssh + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "net" + "runtime" + "strings" + "time" + + "golang.org/x/crypto/ssh" +) + +const ( + apcSSHVer = 1 + + sshTimeout = 90 * time.Second +) + +// APC UPS won't except Go's SSH "Run()" command as the format isn't quite +// the same. Therefore, write a custom implementation instead of relying on +// something like github.com/bramvdbogaerde/go-scp + +type Config struct { + Hostname string + Username string + Password string + ServerFingerprint string + InsecureCipher bool +} + +// Client is an APC UPS SSH client +type Client struct { + hostname string + sshCfg *ssh.ClientConfig +} + +// New creates a new SSH Client for the APC UPS. +func New(cfg *Config) (*Client, error) { + // make host key callback + hk := func(_hostname string, _remote net.Addr, key ssh.PublicKey) error { + // calculate server's key's SHA256 + hasher := sha256.New() + _, err := hasher.Write(key.Marshal()) + if err != nil { + return err + } + actualHash := hasher.Sum(nil) + + // log fingerprint for debugging + actualHashB64 := base64.RawStdEncoding.EncodeToString(actualHash) + actualHashHex := hex.EncodeToString(actualHash) + + // check for fingerprint match (b64 or hex) + if actualHashB64 != cfg.ServerFingerprint && actualHashHex != cfg.ServerFingerprint { + return fmt.Errorf("apcssh: server returned wrong fingerprint (b64: %s ; hex: %s)", actualHashB64, actualHashHex) + } + + return nil + } + + // kex algos + // see defaults: https://cs.opensource.google/go/x/crypto/+/refs/tags/v0.18.0:ssh/common.go;l=62 + kexAlgos := []string{ + "curve25519-sha256", "curve25519-sha256@libssh.org", + "ecdh-sha2-nistp256", "ecdh-sha2-nistp384", "ecdh-sha2-nistp521", + "diffie-hellman-group14-sha256", "diffie-hellman-group14-sha1", + } + // extra for some apc ups + kexAlgos = append(kexAlgos, "diffie-hellman-group-exchange-sha256") + + // ciphers + // see defaults: https://cs.opensource.google/go/x/crypto/+/master:ssh/common.go;l=37 + ciphers := []string{ + "aes128-gcm@openssh.com", "aes256-gcm@openssh.com", + "chacha20-poly1305@openssh.com", + "aes128-ctr", "aes192-ctr", "aes256-ctr", + } + + // insecure cipher options? + if cfg.InsecureCipher { + ciphers = append(ciphers, "aes128-cbc", "3des-cbc") + } + + // install file on UPS + // ssh config + config := &ssh.ClientConfig{ + User: cfg.Username, + Auth: []ssh.AuthMethod{ + ssh.Password(cfg.Password), + }, + // APC seems to require `Client Version` string to start with "SSH-2" and must be at least + // 13 characters long + // working examples from other clients: + // ClientVersion: "SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.6", + // ClientVersion: "SSH-2.0-PuTTY_Release_0.80", + ClientVersion: fmt.Sprintf("SSH-2.0-apcssh_v%d %s-%s", apcSSHVer, runtime.GOOS, runtime.GOARCH), + Config: ssh.Config{ + KeyExchanges: kexAlgos, + Ciphers: ciphers, + }, + HostKeyCallback: hk, + + // reasonable timeout for file copy + Timeout: sshTimeout, + } + + // if hostname missing a port, add default + if !strings.Contains(cfg.Hostname, ":") { + cfg.Hostname = cfg.Hostname + ":22" + } + + // connect to ups over SSH (to verify everything works) + sshClient, err := ssh.Dial("tcp", cfg.Hostname, config) + if err != nil { + return nil, err + } + _ = sshClient.Close() + + // return Client (note: new ssh Dial will be done for each action as the UPS + // seems to not do well with more than one Session per Dial) + return &Client{ + hostname: cfg.Hostname, + sshCfg: config, + }, nil +} 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/cmd_restartwebui.go b/pkg/apcssh/cmd_restartwebui.go new file mode 100644 index 0000000..5196212 --- /dev/null +++ b/pkg/apcssh/cmd_restartwebui.go @@ -0,0 +1,22 @@ +package apcssh + +import ( + "fmt" + "strings" +) + +// RestartWebUI sends the APC command to restart the web ui +// WARNING: Sending a command directly after this one will cause issues. +// This command will cause SSH to also restart after a slight delay, therefore +// any command right after this will start to run but then get stuck / fail +// somewhere in the middle. +func (cli *Client) RestartWebUI() error { + result, err := cli.cmd("reboot -Y") + if err != nil { + return fmt.Errorf("apcssh: failed to restart web ui (%w)", err) + } else if !strings.EqualFold(result.code, "e000") { + return fmt.Errorf("apcssh: failed to restart web ui (%s: %s)", result.code, result.codeText) + } + + return nil +} diff --git a/pkg/apcssh/scp.go b/pkg/apcssh/scp.go new file mode 100644 index 0000000..a37d154 --- /dev/null +++ b/pkg/apcssh/scp.go @@ -0,0 +1,129 @@ +package apcssh + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "io/fs" + "path" + + "golang.org/x/crypto/ssh" +) + +// UploadSCP uploads a file to the destination specified (e.g., "/ssl/file.key") +// containing the file content specified. An existing file at the destination +// will be overwritten without warning. +func (cli *Client) UploadSCP(destination string, fileContent []byte, filePermissions fs.FileMode) error { + // connect + sshClient, err := ssh.Dial("tcp", cli.hostname, cli.sshCfg) + if err != nil { + return fmt.Errorf("apcssh: scp: failed to dial client (%w)", err) + } + defer sshClient.Close() + + // make session to use for SCP + session, err := sshClient.NewSession() + if err != nil { + return fmt.Errorf("apcssh: scp: failed to create session (%w)", err) + } + defer session.Close() + + // attach pipes + out, err := session.StdoutPipe() + if err != nil { + return err + } + w, err := session.StdinPipe() + if err != nil { + return err + } + defer w.Close() + + // send execute cmd -- + // build cmd to send as request + // Go implementation sends additional 0x22 bytes when using Run() (as + // compared to putty's scp tool). these additional bytes seem to cause the + // apc ups to fail execution of the command + payload := []byte(fmt.Sprintf("scp -q -t %s", destination)) + payloadLen := uint8(len(payload)) + payload = append([]byte{0, 0, 0, payloadLen}, payload...) + + ok, err := session.SendRequest("exec", true, payload) + if err != nil { + return fmt.Errorf("apcssh: scp: failed to execute scp cmd (%w)", err) + } + if !ok { + return errors.New("apcssh: scp: execute scp cmd not ok") + } + + // check remote response + // Note: File upload may not work if the client doesn't actually read from + // the remote output. + err = scpCheckResponse(out) + if err != nil { + return fmt.Errorf("apcssh: scp: failed to send scp cmd (bad remote response 1) (%w)", err) + } + + // just file name (without path) + filename := path.Base(destination) + + // send file header + _, err = fmt.Fprintln(w, "C"+fmt.Sprintf("%04o", filePermissions.Perm()), len(fileContent), filename) + if err != nil { + return fmt.Errorf("apcssh: scp: failed to send file info (%w)", err) + } + + err = scpCheckResponse(out) + if err != nil { + return fmt.Errorf("apcssh: scp: failed to send file info (bad remote response 2) (%w)", err) + } + + // send actual file + _, err = io.Copy(w, bytes.NewReader(fileContent)) + if err != nil { + return fmt.Errorf("apcssh: scp: failed to send file(%w)", err) + } + + // send file end + _, err = fmt.Fprint(w, "\x00") + if err != nil { + return fmt.Errorf("apcssh: scp: failed to send final 00 byte (%w)", err) + } + + err = scpCheckResponse(out) + if err != nil { + return fmt.Errorf("apcssh: scp: failed to send file (bad remote response 3) (%w)", err) + } + + // done + return nil +} + +// scpCheckResponse reads the output from the remote and returns an error +// if the remote output was not 0 +func scpCheckResponse(remoteOutPipe io.Reader) error { + buffer := make([]uint8, 1) + _, err := remoteOutPipe.Read(buffer) + if err != nil { + return fmt.Errorf("apcssh: failed to read output buffer (%w)", err) + } + + responseType := buffer[0] + message := "" + if responseType > 0 { + bufferedReader := bufio.NewReader(remoteOutPipe) + message, err = bufferedReader.ReadString('\n') + if err != nil { + return fmt.Errorf("apcssh: failed to read output buffer (%w)", err) + } + } + + // if not 0 (aka OK) + if responseType != 0 { + return fmt.Errorf("apcssh: remote returned error (%d: %s)", responseType, message) + } + + return nil +} diff --git a/pkg/apcssh/shell.go b/pkg/apcssh/shell.go new file mode 100644 index 0000000..bb05639 --- /dev/null +++ b/pkg/apcssh/shell.go @@ -0,0 +1,139 @@ +package apcssh + +import ( + "bufio" + "errors" + "fmt" + "strings" + "time" + + "golang.org/x/crypto/ssh" +) + +// Abort shell connection if UPS doesn't send a recognizable response within +// the specified timeouts; Cmd timeout is very long as it is unlikely to be +// needed but still exists to avoid an indefinite hang in the unlikely event +// something does go wrong at that part of the app +const ( + shellTimeoutLogin = 20 * time.Second + shellTimeoutCmd = 5 * time.Minute +) + +// upsCmdResult is a structure that holds all of a shell commands results +type upsCmdResult struct { + command string + code string + codeText string + resultText string +} + +// cmd creates an interactive shell and executes the specified command +func (cli *Client) cmd(command string) (*upsCmdResult, error) { + // connect + sshClient, err := ssh.Dial("tcp", cli.hostname, cli.sshCfg) + if err != nil { + return nil, fmt.Errorf("failed to dial client (%w)", err) + } + defer sshClient.Close() + + session, err := sshClient.NewSession() + if err != nil { + return nil, fmt.Errorf("failed to create session (%w)", err) + } + defer session.Close() + + // pipes to send shell command to; and to receive repsonse + sshInput, err := session.StdinPipe() + if err != nil { + return nil, fmt.Errorf("failed to make shell input pipe (%w)", err) + } + sshOutput, err := session.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("failed to make shell output pipe (%w)", err) + } + + // make scanner to read shell output continuously + scanner := bufio.NewScanner(sshOutput) + scanner.Split(scanAPCShell) + + // start interactive shell + if err := session.Shell(); err != nil { + return nil, fmt.Errorf("failed to start shell (%w)", err) + } + + // use a timer to close the session early in case Scan() hangs (which can + // happen if the UPS provides output this app does not understand) + cancelAbort := make(chan struct{}) + defer close(cancelAbort) + go func() { + select { + case <-time.After(shellTimeoutLogin): + _ = session.Close() + + case <-cancelAbort: + // aborted cancel (i.e., succesful Scan()) + } + }() + + // check shell response after connect + scannedOk := scanner.Scan() + // if failed to scan (e.g., timer closed the session after timeout) + if !scannedOk { + return nil, errors.New("shell did not return parsable login response") + } + // success; cancel abort timer + cancelAbort <- struct{}{} + // discard the initial shell response (login message(s) / initial shell prompt) + _ = scanner.Bytes() + + // send command + _, err = fmt.Fprint(sshInput, command+"\n") + if err != nil { + return nil, fmt.Errorf("failed to send shell command (%w)", err) + } + + // use a timer to close the session early in case Scan() hangs (which can + // happen if the UPS provides output this app does not understand); + // since initial login message Scan() was okay, it is relatively unlikely this + // will hang + go func() { + select { + case <-time.After(shellTimeoutCmd): + _ = session.Close() + + case <-cancelAbort: + // aborted cancel (i.e., succesful Scan()) + } + }() + + // check shell response to command + scannedOk = scanner.Scan() + // if failed to scan (e.g., timer closed the session after timeout) + if !scannedOk { + return nil, fmt.Errorf("shell did not return parsable response to cmd '%s'", command) + } + // success; cancel abort timer + cancelAbort <- struct{}{} + + // parse the UPS response into result struct and return + upsRawResponse := string(scanner.Bytes()) + result := &upsCmdResult{} + + cmdIndx := strings.Index(upsRawResponse, "\n") + result.command = upsRawResponse[:cmdIndx-1] + upsRawResponse = upsRawResponse[cmdIndx+1:] + + codeIndx := strings.Index(upsRawResponse, ": ") + result.code = upsRawResponse[:codeIndx] + upsRawResponse = upsRawResponse[codeIndx+2:] + + codeTxtIndx := strings.Index(upsRawResponse, "\n") + result.codeText = upsRawResponse[:codeTxtIndx-1] + + // avoid out of bounds if no result text + if codeTxtIndx+1 <= len(upsRawResponse)-2 { + result.resultText = upsRawResponse[codeTxtIndx+1 : len(upsRawResponse)-2] + } + + return result, nil +} diff --git a/pkg/apcssh/shell_helpers.go b/pkg/apcssh/shell_helpers.go new file mode 100644 index 0000000..02c5e81 --- /dev/null +++ b/pkg/apcssh/shell_helpers.go @@ -0,0 +1,38 @@ +package apcssh + +import ( + "io" + "regexp" +) + +// scanAPCShell is a SplitFunc to capture shell output after each interactive +// shell command is run +func scanAPCShell(data []byte, atEOF bool) (advance int, token []byte, err error) { + // EOF is not an expected response and should error (e.g., when the output pipe + // gets closed by timeout) + if atEOF { + return len(data), dropCR(data), io.ErrUnexpectedEOF + } else if len(data) == 0 { + // no data to process, request more data + return 0, nil, nil + } + + // regex for shell prompt (e.g., `apc@apc>`, `apc>`, `some@dev>`, `other123>`, etc.) + re := regexp.MustCompile(`(\r\n|\r|\n)([A-Za-z0-9.]+@?)?[A-Za-z0-9.]+>`) + // find match for prompt + if index := re.FindStringIndex(string(data)); index != nil { + // advance starts after the prompt; token is everything before the prompt + return index[1], dropCR(data[0:index[0]]), nil + } + + // no match, request more data + return 0, nil, nil +} + +// dropCR drops a terminal \r from the data. +func dropCR(data []byte) []byte { + if len(data) > 0 && data[len(data)-1] == '\r' { + return data[0 : len(data)-1] + } + return data +} diff --git a/pkg/apcssh/ssl.go b/pkg/apcssh/ssl.go new file mode 100644 index 0000000..18bff70 --- /dev/null +++ b/pkg/apcssh/ssl.go @@ -0,0 +1,86 @@ +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. +func (cli *Client) InstallSSLCert(keyP15 []byte, certPem []byte, keyCertP15 []byte) error { + // run `ssl` command to check if it exists + result, err := cli.cmd("ssl") + if err != nil { + return fmt.Errorf("apcssh: ssl cert install: failed to test ssl cmd (%w)", err) + } + // E101 is the code for "Command Not Found" + supportsSSLCmd := !strings.EqualFold(result.code, "e101") + + // if SSL is supported, use that method + if supportsSSLCmd { + return cli.installSSLCertModern(keyP15, certPem) + } + + // fallback to legacy + return cli.installSSLCertLegacy(keyCertP15) +} + +// 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 { + return fmt.Errorf("apcssh: ssl cert install: failed to send nmc.key file to ups over scp (%w)", err) + } + + // upload the cert PEM file + err = cli.UploadSCP("/ssl/nmc.crt", certPem, 0666) + if err != nil { + return fmt.Errorf("apcssh: ssl cert install: failed to send nmc.key file to ups over scp (%w)", err) + } + + // run `ssl` install commands + result, err := cli.cmd("ssl key -i /ssl/nmc.key") + if err != nil { + return fmt.Errorf("apcssh: ssl cert install: failed to send ssl key install cmd (%w)", err) + } else if !strings.EqualFold(result.code, "e000") { + return fmt.Errorf("apcssh: ssl cert install: ssl key install cmd returned error code (%s: %s)", result.code, result.codeText) + } + + result, err = cli.cmd("ssl cert -i /ssl/nmc.crt") + if err != nil { + return fmt.Errorf("apcssh: ssl cert install: failed to send ssl cert install cmd (%w)", err) + } else if !strings.EqualFold(result.code, "e000") { + return fmt.Errorf("apcssh: ssl cert install: ssl cert install cmd returned error code (%s: %s)", result.code, result.codeText) + } + + return nil +} + +// installSSLCertLegacy installs the SSL key and certificate by directly uploading +// 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 { + return fmt.Errorf("apcssh: ssl cert install: failed to send defaultcert.p15 file to ups over scp (%w)", err) + } + + return nil +} diff --git a/pkg/app/app.go b/pkg/app/app.go index 3119334..052a00e 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -12,7 +12,7 @@ import ( ) const ( - appVersion = "0.4.2" + appVersion = "1.2.2" ) // struct for receivers to use common app pieces diff --git a/pkg/app/cmd_create.go b/pkg/app/cmd_create.go index 51cf1cc..8791a81 100644 --- a/pkg/app/cmd_create.go +++ b/pkg/app/cmd_create.go @@ -2,18 +2,19 @@ package app import ( "context" + "encoding/base64" "fmt" "os" ) -const createDefaultOutFilePath = "apctool.p15" +const ( + createDefaultOutFilePath = "apctool.p15" + createDefaultOutKeyFilePath = "apctool.key.p15" +) // cmdCreate is the app's command to create an apc p15 file from key and cert // pem files func (app *app) cmdCreate(_ context.Context, args []string) error { - // done - defer app.stdLogger.Println("create: done") - // extra args == error if len(args) != 0 { return fmt.Errorf("create: failed, %w (%d)", ErrExtraArgs, len(args)) @@ -26,25 +27,67 @@ func (app *app) cmdCreate(_ context.Context, args []string) error { // validation done - // make p15 file - apcFile, err := app.pemToAPCP15(keyPem, certPem, "create") + // make p15 files + keyFile, apcKeyCertFile, err := app.pemToAPCP15(keyPem, certPem, "create") if err != nil { return err } // determine file name (should already be done by flag parsing, but avoid nil just in case) - fileName := createDefaultOutFilePath + keyCertFileName := createDefaultOutFilePath if app.config.create.outFilePath != nil && *app.config.create.outFilePath != "" { - fileName = *app.config.create.outFilePath + keyCertFileName = *app.config.create.outFilePath } - // write file - err = os.WriteFile(fileName, apcFile, 0777) + keyFileName := createDefaultOutFilePath + if app.config.create.outKeyFilePath != nil && *app.config.create.outKeyFilePath != "" { + keyFileName = *app.config.create.outKeyFilePath + } + + // write file(s) + err = os.WriteFile(keyFileName, keyFile, 0600) if err != nil { - return fmt.Errorf("create: failed to write apc p15 file (%s)", err) + return fmt.Errorf("create: failed to write apc p15 key file (%s)", err) + } + app.stdLogger.Printf("create: apc p15 key file %s written to disk", keyFileName) + + // 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 file %s written to disk", fileName) + // if debug, write additional debug files (b64 format to make copy/paste into asn1 decoder + // easy to do e.g., https://lapo.it/asn1js) + if app.config.debugLogging != nil && *app.config.debugLogging { + keyFileNameDebug := keyFileName + ".b64" + err = os.WriteFile(keyFileNameDebug, []byte(base64.StdEncoding.EncodeToString(keyFile)), 0600) + if err != nil { + return fmt.Errorf("create: failed to write apc p15 key file (%s)", err) + } + app.debugLogger.Printf("create: apc p15 key file %s written to disk", keyFileNameDebug) + + // 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) + } + app.debugLogger.Printf("create: apc p15 key+cert file header %s written to disk", keyCertFileNameHeaderDebug) + } + + } return nil } diff --git a/pkg/app/cmd_install.go b/pkg/app/cmd_install.go index 5d826ec..eacda53 100644 --- a/pkg/app/cmd_install.go +++ b/pkg/app/cmd_install.go @@ -1,24 +1,22 @@ package app import ( + "apc-p15-tool/pkg/apcssh" + "bytes" "context" - "crypto/sha256" - "encoding/base64" - "encoding/hex" + "crypto/tls" + "encoding/pem" "errors" "fmt" - "net" - "runtime" - - "golang.org/x/crypto/ssh" + "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 { - // done - defer app.stdLogger.Println("install: done") - // extra args == error if len(args) != 0 { return fmt.Errorf("install: failed, %w (%d)", ErrExtraArgs, len(args)) @@ -45,126 +43,64 @@ 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") } // validation done // make p15 file - apcFile, err := app.pemToAPCP15(keyPem, certPem, "install") + keyP15, keyCertP15, err := app.pemToAPCP15(keyPem, certPem, "install") if err != nil { return err } - // make host key callback - hk := func(hostname string, remote net.Addr, key ssh.PublicKey) error { - // calculate server's key's SHA256 - hasher := sha256.New() - _, err := hasher.Write(key.Marshal()) - if err != nil { - return err - } - actualHash := hasher.Sum(nil) - - // log fingerprint for debugging - actualHashB64 := base64.RawStdEncoding.EncodeToString(actualHash) - actualHashHex := hex.EncodeToString(actualHash) - app.debugLogger.Printf("ssh: remote server key fingerprint (b64): %s", actualHashB64) - app.debugLogger.Printf("ssh: remote server key fingerprint (hex): %s", actualHashHex) - - // allow base64 format - if actualHashB64 == *app.config.install.fingerprint { - return nil - } - - // allow hex format - if actualHashHex == *app.config.install.fingerprint { - return nil - } - - return errors.New("ssh: fingerprint didn't match") - } - - // kex algos - // see defaults: https://cs.opensource.google/go/x/crypto/+/refs/tags/v0.18.0:ssh/common.go;l=62 - kexAlgos := []string{ - "curve25519-sha256", "curve25519-sha256@libssh.org", - "ecdh-sha2-nistp256", "ecdh-sha2-nistp384", "ecdh-sha2-nistp521", - "diffie-hellman-group14-sha256", "diffie-hellman-group14-sha1", - } - // extra for some apc ups - kexAlgos = append(kexAlgos, "diffie-hellman-group-exchange-sha256") - - // ciphers - // see defaults: https://cs.opensource.google/go/x/crypto/+/master:ssh/common.go;l=37 - ciphers := []string{ - "aes128-gcm@openssh.com", "aes256-gcm@openssh.com", - "chacha20-poly1305@openssh.com", - "aes128-ctr", "aes192-ctr", "aes256-ctr", - } - - // insecure cipher options? + // log warning if insecure cipher if app.config.install.insecureCipher != nil && *app.config.install.insecureCipher { - app.stdLogger.Println("WARNING: insecure ciphers are enabled (--insecurecipher). SSH with an insecure cipher is NOT secure and should NOT be used.") - ciphers = append(ciphers, "aes128-cbc", "3des-cbc") + app.stdLogger.Println("WARNING: install: insecure ciphers are enabled (--insecurecipher). SSH with an insecure cipher is NOT secure and should NOT be used.") } - // install file on UPS - // ssh config - config := &ssh.ClientConfig{ - User: *app.config.install.username, - Auth: []ssh.AuthMethod{ - ssh.Password(*app.config.install.password), - }, - // APC seems to require `Client Version` string to start with "SSH-2" and must be at least - // 13 characters long - // working examples from other clients: - // ClientVersion: "SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.6", - // ClientVersion: "SSH-2.0-PuTTY_Release_0.80", - ClientVersion: fmt.Sprintf("SSH-2.0-apc-p15-tool_v%s %s-%s", appVersion, runtime.GOOS, runtime.GOARCH), - Config: ssh.Config{ - KeyExchanges: kexAlgos, - Ciphers: ciphers, - // MACs: []string{"hmac-sha2-256"}, - }, - // HostKeyAlgorithms: []string{"ssh-rsa"}, - HostKeyCallback: hk, - - // reasonable timeout for file copy - Timeout: sshScpTimeout, + // make APC SSH client + cfg := &apcssh.Config{ + 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, + InsecureCipher: *app.config.install.insecureCipher, } - // connect to ups over SSH - client, err := ssh.Dial("tcp", *app.config.install.hostAndPort, config) + client, err := apcssh.New(cfg) if err != nil { return fmt.Errorf("install: failed to connect to host (%w)", err) } - defer client.Close() + app.stdLogger.Println("install: connected to ups ssh, installing ssl key and cert...") - // send file to UPS - err = sshScpSendFileToUPS(client, apcFile) + // check time - don't fail it time is no good, just do logging here + upsT, err := client.GetTime() if err != nil { - return fmt.Errorf("install: failed to send p15 file to ups over scp (%w)", err) + 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 { + return fmt.Errorf("install: %w", err) } // 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 { app.stdLogger.Println("install: sending restart command") - // connect to ups over SSH - // opening a second session doesn't seem to work with my NMC2 for some reason, so make - // a new connection instead - client, err = ssh.Dial("tcp", *app.config.install.hostAndPort, config) - if err != nil { - return fmt.Errorf("install: failed to reconnect to host to send webui restart command (%w)", err) - } - defer client.Close() - - err = sshResetUPSWebUI(client) + err = client.RestartWebUI() if err != nil { return fmt.Errorf("install: failed to send webui restart command (%w)", err) } @@ -172,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") + } + + app.stdLogger.Println("install: ups web ui cert verified") + } + return nil } diff --git a/pkg/app/config.go b/pkg/app/config.go index 6514391..7edf216 100644 --- a/pkg/app/config.go +++ b/pkg/app/config.go @@ -28,15 +28,19 @@ type config struct { debugLogging *bool create struct { keyCertPemCfg - outFilePath *string + outFilePath *string + outKeyFilePath *string } install struct { keyCertPemCfg - hostAndPort *string + hostname *string + sshport *int fingerprint *string username *string password *string restartWebUI *bool + webUISSLPort *int + skipVerify *bool insecureCipher *bool } } @@ -56,7 +60,7 @@ func (app *app) getConfig(args []string) error { // apc-p15-tool -- root command rootFlags := ff.NewFlagSet("apc-p15-tool") - cfg.debugLogging = rootFlags.BoolLong("debug", "set this flag to enable additional debug logging messages") + cfg.debugLogging = rootFlags.BoolLong("debug", "set this flag to enable additional debug logging messages and files") rootCmd := &ff.Command{ Name: "apc-p15-tool", @@ -67,11 +71,12 @@ 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 p15 file to") + 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") createCmd := &ff.Command{ Name: "create", @@ -86,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/file_header.go b/pkg/app/file_header.go index b803d68..20097cd 100644 --- a/pkg/app/file_header.go +++ b/pkg/app/file_header.go @@ -7,6 +7,8 @@ import ( "github.com/sigurn/crc16" ) +const apcHeaderLen = 228 + // makeFileHeader generates the 228 byte header to prepend to the .p15 // as required by APC UPS NMC. Contrary to the apc_tools repo, it does // mot appear the header changes based on key size. @@ -28,7 +30,7 @@ func makeFileHeader(p15File []byte) ([]byte, error) { // *(uint32_t *)(buf + 208) = keySize; // 1 for 1024 key, otherwise (2048 bit) 2 // Unsure why this was in original code but seems irrelevant - header := make([]byte, 228) + header := make([]byte, apcHeaderLen) // always 1 header[0] = 1 diff --git a/pkg/app/pem_to_p15.go b/pkg/app/pem_to_p15.go index eb020aa..d48d4a8 100644 --- a/pkg/app/pem_to_p15.go +++ b/pkg/app/pem_to_p15.go @@ -3,36 +3,63 @@ package app import ( "apc-p15-tool/pkg/pkcs15" "fmt" + "slices" ) -// pemToAPCP15 reads the specified pem files and returns the apc p15 bytes -func (app *app) pemToAPCP15(keyPem, certPem []byte, parentCmdName string) ([]byte, error) { - app.stdLogger.Printf("%s: making apc p15 file from pem", parentCmdName) +// list of keys supported by the NMC2 +var nmc2SupportedKeyTypes = []pkcs15.KeyType{ + pkcs15.KeyTypeRSA1024, + pkcs15.KeyTypeRSA2048, + pkcs15.KeyTypeRSA3072, // officially not supported but works +} + +// 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(s) content from pem", parentCmdName) // make p15 struct p15, err := pkcs15.ParsePEMToPKCS15(keyPem, certPem) if err != nil { - return nil, fmt.Errorf("%s: failed to parse pem files (%w)", parentCmdName, err) + 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 - p15File, err := p15.ToP15File() + // make key file (always) + keyFile, err = p15.ToP15Key() if err != nil { - return 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(p15File) - if err != nil { - return 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 + if slices.Contains(nmc2SupportedKeyTypes, p15.KeyType()) { + 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...) + } else { + // NMC2 unsupported + app.stdLogger.Printf("%s: key type is not supported by NMC2, skipping p15 key+cert file content", parentCmdName) } - // combine header with file - apcFile := append(apcHeader, p15File...) + app.stdLogger.Printf("%s: apc p15 file(s) data succesfully generated", parentCmdName) - app.stdLogger.Printf("%s: apc p15 file data succesfully generated", parentCmdName) - - return apcFile, nil + return keyFile, apcKeyCertFile, nil } diff --git a/pkg/app/ssh_resetwebui.go b/pkg/app/ssh_resetwebui.go deleted file mode 100644 index 52f90d9..0000000 --- a/pkg/app/ssh_resetwebui.go +++ /dev/null @@ -1,45 +0,0 @@ -package app - -import ( - "errors" - "fmt" - - "golang.org/x/crypto/ssh" -) - -// sshResetUPSWebUI sends a command to the UPS to restart the WebUI. This -// command is supposed to be required to load the new cert, but that -// doesn't seem to be true (at least it isn't on my UPS). Adding the -// option though, in case other UPS might need it. -func sshResetUPSWebUI(client *ssh.Client) error { - // make session to use for restart command - session, err := client.NewSession() - if err != nil { - return fmt.Errorf("ssh: restart: failed to create session (%w)", err) - } - defer session.Close() - - // start shell - err = session.Shell() - if err != nil { - return fmt.Errorf("ssh: restart: failed to start shell (%w)", err) - } - - // execure reboot via SendRequest - payload := []byte("reboot -Y") - payloadLen := uint8(len(payload)) - payload = append([]byte{0, 0, 0, payloadLen}, payload...) - - ok, err := session.SendRequest("exec", true, payload) - if err != nil { - return fmt.Errorf("ssh: scp: failed to execute scp cmd (%w)", err) - } - if !ok { - return errors.New("ssh: scp: execute scp cmd not ok") - } - - // don't read remote output, as nothing interesting actually outputs - - // done - return nil -} diff --git a/pkg/app/ssh_response.go b/pkg/app/ssh_response.go deleted file mode 100644 index 3b44523..0000000 --- a/pkg/app/ssh_response.go +++ /dev/null @@ -1,34 +0,0 @@ -package app - -import ( - "bufio" - "fmt" - "io" -) - -// sshCheckResponse reads the output from the remote and returns an error -// if the remote output was not 0 -func sshCheckResponse(remoteOutPipe io.Reader) error { - buffer := make([]uint8, 1) - _, err := remoteOutPipe.Read(buffer) - if err != nil { - return fmt.Errorf("ssh: failed to make read output buffer (%w)", err) - } - - responseType := buffer[0] - message := "" - if responseType > 0 { - bufferedReader := bufio.NewReader(remoteOutPipe) - message, err = bufferedReader.ReadString('\n') - if err != nil { - return fmt.Errorf("ssh: failed to read output buffer (%w)", err) - } - } - - // if not 0 (aka OK) - if responseType != 0 { - return fmt.Errorf("ssh: remote returned error (%d: %s)", responseType, message) - } - - return nil -} diff --git a/pkg/app/ssh_scp.go b/pkg/app/ssh_scp.go deleted file mode 100644 index 54f216c..0000000 --- a/pkg/app/ssh_scp.go +++ /dev/null @@ -1,105 +0,0 @@ -package app - -import ( - "bytes" - "errors" - "fmt" - "io" - "path" - "time" - - "golang.org/x/crypto/ssh" -) - -// APC UPS won't except Go's SSH "Run()" command as the format isn't quite -// the same. Therefore, write a custom implementation to send the desired -// command instead of relying on something like github.com/bramvdbogaerde/go-scp - -const ( - sshScpP15Destination = "/ssl/defaultcert.p15" - sshScpP15PermissionsStr = "0600" - - sshScpTimeout = 90 * time.Second -) - -// sshScpSendFileToUPS sends the p15File to the APC UPS via the SCP protocol. it is -// automatically placed in the correct directory and will overwrite any existing -// file -func sshScpSendFileToUPS(client *ssh.Client, p15File []byte) error { - // make session to use for SCP - session, err := client.NewSession() - if err != nil { - return fmt.Errorf("ssh: scp: failed to create session (%w)", err) - } - defer session.Close() - - // attach pipes - out, err := session.StdoutPipe() - if err != nil { - return err - } - w, err := session.StdinPipe() - if err != nil { - return err - } - defer w.Close() - - // send execute cmd -- - // build cmd to send as request - // Go implementation sends additional 0x22 bytes when using Run() (as - // compared to putty's scp tool). these additional bytes seem to cause the - // apc ups to fail execution of the command - payload := []byte(fmt.Sprintf("scp -q -t %s", sshScpP15Destination)) - payloadLen := uint8(len(payload)) - payload = append([]byte{0, 0, 0, payloadLen}, payload...) - - ok, err := session.SendRequest("exec", true, payload) - if err != nil { - return fmt.Errorf("ssh: scp: failed to execute scp cmd (%w)", err) - } - if !ok { - return errors.New("ssh: scp: execute scp cmd not ok") - } - - // check remote response - // Note: File upload may not work if the client doesn't actually read from - // the remote output. - err = sshCheckResponse(out) - if err != nil { - return fmt.Errorf("ssh: scp: failed to send scp cmd (bad remote response) (%w)", err) - } - - // just file name (without path) - filename := path.Base(sshScpP15Destination) - - // send file header - _, err = fmt.Fprintln(w, "C"+sshScpP15PermissionsStr, len(p15File), filename) - if err != nil { - return fmt.Errorf("ssh: scp: failed to send file info (%w)", err) - } - - err = sshCheckResponse(out) - if err != nil { - return fmt.Errorf("ssh: scp: failed to send file info (bad remote response) (%w)", err) - } - - // send actual file - _, err = io.Copy(w, bytes.NewReader(p15File)) - if err != nil { - return fmt.Errorf("ssh: scp: failed to send file(%w)", err) - } - - // send file end - _, err = fmt.Fprint(w, "\x00") - if err != nil { - return fmt.Errorf("ssh: scp: failed to send final 00 byte (%w)", err) - } - - err = sshCheckResponse(out) - if err != nil { - return fmt.Errorf("ssh: scp: failed to send file (bad remote response) (%w)", err) - } - - // done - return nil -} diff --git a/pkg/pkcs15/encrypted_envelope.go b/pkg/pkcs15/encrypted_envelope.go index cead3c8..71433d1 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 p15.envelopedPrivateKey != nil && 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 76f4297..08a3ce4 100644 --- a/pkg/pkcs15/keyid.go +++ b/pkg/pkcs15/keyid.go @@ -2,6 +2,8 @@ package pkcs15 import ( "apc-p15-tool/pkg/tools/asn1obj" + "crypto/ecdsa" + "crypto/rsa" "crypto/sha1" "encoding/binary" "math/big" @@ -11,22 +13,6 @@ import ( func (p15 *pkcs15KeyCert) keyId() []byte { // object to hash is just the RawSubjectPublicKeyInfo - // Create Object to hash - // hashObj := asn1obj.Sequence([][]byte{ - // asn1obj.Sequence([][]byte{ - // // Key is RSA - // asn1obj.ObjectIdentifier(asn1obj.OIDrsaEncryptionPKCS1), - // asn1.NullBytes, - // }), - // // BIT STRING of rsa key public key - // asn1obj.BitString( - // asn1obj.Sequence([][]byte{ - // asn1obj.Integer(p15.key.N), - // asn1obj.Integer((big.NewInt(int64(p15.key.E)))), - // }), - // ), - // }) - // SHA-1 Hash hasher := sha1.New() _, err := hasher.Write(p15.cert.RawSubjectPublicKeyInfo) @@ -124,18 +110,32 @@ func (p15 *pkcs15KeyCert) keyIdInt7() []byte { } // keyIdInt8 returns the sequence for keyId with INT val of 8; This value is equivelant -// to "pgp", which is PGP v3 key Id. This value is just the last 8 bytes of the public -// key N value +// to "pgp", which is PGP v3 key Id. func (p15 *pkcs15KeyCert) keyIdInt8() []byte { - nBytes := p15.key.N.Bytes() + var keyIdVal []byte + + switch privKey := p15.key.(type) { + case *rsa.PrivateKey: + // RSA: The ID value is just the last 8 bytes of the public key N value + 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 unexpected key type + panic("key id 8 for key is unexpected and unsupported") + } // object to return - obj := asn1obj.Sequence([][]byte{ + idObj := asn1obj.Sequence([][]byte{ asn1obj.Integer(big.NewInt(8)), - asn1obj.OctetString(nBytes[len(nBytes)-8:]), + asn1obj.OctetString(keyIdVal), }) - return obj + return idObj } // bigIntToMpi returns the MPI (as defined in RFC 4880 s 3.2) from a given @@ -156,33 +156,44 @@ func (p15 *pkcs15KeyCert) keyIdInt9() []byte { // Public-Key packet starting with the version field. The Key ID is the // low-order 64 bits of the fingerprint. - // the entire Public-Key packet + // first make the public key packet publicKeyPacket := []byte{} // starting with the version field (A one-octet version number (4)). publicKeyPacket = append(publicKeyPacket, byte(4)) // A four-octet number denoting the time that the key was created. - time := make([]byte, 4) - // NOTE: use cert validity start as proxy for key creation since key pem // doesn't actually contain a created at time -- in reality notBefore tends // 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())) publicKeyPacket = append(publicKeyPacket, time...) - // A one-octet number denoting the public-key algorithm of this key. - // 1 - RSA (Encrypt or Sign) [HAC] - publicKeyPacket = append(publicKeyPacket, byte(1)) + // the next part is key type specific + switch privKey := p15.key.(type) { + case *rsa.PrivateKey: + // A one-octet number denoting the public-key algorithm of this key. + // 1 - RSA (Encrypt or Sign) [HAC] + publicKeyPacket = append(publicKeyPacket, byte(1)) - // Algorithm-Specific Fields for RSA public keys: - // multiprecision integer (MPI) of RSA public modulus n - publicKeyPacket = append(publicKeyPacket, bigIntToMpi(p15.key.N)...) + // Algorithm-Specific Fields for RSA public keys: + // multiprecision integer (MPI) of RSA public modulus n + publicKeyPacket = append(publicKeyPacket, bigIntToMpi(privKey.N)...) - // MPI of RSA public encryption exponent e - e := big.NewInt(int64(p15.key.PublicKey.E)) - publicKeyPacket = append(publicKeyPacket, bigIntToMpi(e)...) + // MPI of RSA public encryption exponent e + e := big.NewInt(int64(privKey.PublicKey.E)) + publicKeyPacket = append(publicKeyPacket, bigIntToMpi(e)...) + + case *ecdsa.PrivateKey: + // don't use this key id, leave empty + return nil + + default: + // panic if unexpected key type + panic("key id 9 for key is unexpected and unsupported") + } // Assemble the V4 byte array that will be hashed // 0x99 (1 octet) @@ -205,10 +216,10 @@ func (p15 *pkcs15KeyCert) keyIdInt9() []byte { keyId := sha1Hash[len(sha1Hash)-8:] // object to return - obj := asn1obj.Sequence([][]byte{ + idObj := asn1obj.Sequence([][]byte{ asn1obj.Integer(big.NewInt(9)), asn1obj.OctetString(keyId), }) - return obj + return idObj } diff --git a/pkg/pkcs15/pem_decode.go b/pkg/pkcs15/pem_decode.go index d4b8764..2d51837 100644 --- a/pkg/pkcs15/pem_decode.go +++ b/pkg/pkcs15/pem_decode.go @@ -1,28 +1,37 @@ package pkcs15 import ( + "crypto" + "crypto/ecdsa" "crypto/rsa" "crypto/tls" "crypto/x509" "encoding/pem" "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 or 2,048 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 -func pemKeyDecode(keyPem []byte) (*rsa.PrivateKey, error) { +// 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)) if pemBlock == nil { @@ -30,13 +39,11 @@ func pemKeyDecode(keyPem []byte) (*rsa.PrivateKey, error) { } // parsing depends on block type - var rsaKey *rsa.PrivateKey + var privateKey crypto.PrivateKey switch pemBlock.Type { case "RSA PRIVATE KEY": // PKCS1 - var err error - - rsaKey, err = x509.ParsePKCS1PrivateKey(pemBlock.Bytes) + rsaKey, err := x509.ParsePKCS1PrivateKey(pemBlock.Bytes) if err != nil { return nil, errPemKeyFailedToParse } @@ -47,12 +54,27 @@ func pemKeyDecode(keyPem []byte) (*rsa.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 { - 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 + ecdKey, err := x509.ParseECPrivateKey(pemBlock.Bytes) + if err != nil { + return nil, errPemKeyFailedToParse + } + + // verify supported curve name + if !slices.Contains(supportedECDSACurves, ecdKey.Curve.Params().Name) { + return nil, errKeyWrongType + } + + // good to go + privateKey = ecdKey case "PRIVATE KEY": // PKCS8 pkcs8Key, err := x509.ParsePKCS8PrivateKey(pemBlock.Bytes) @@ -62,23 +84,31 @@ func pemKeyDecode(keyPem []byte) (*rsa.PrivateKey, error) { switch pkcs8Key := pkcs8Key.(type) { case *rsa.PrivateKey: - rsaKey = pkcs8Key - // basic sanity check - err = rsaKey.Validate() + err = pkcs8Key.Validate() if err != nil { return nil, fmt.Errorf("pkcs15: pem key: failed sanity check (%s)", err) } - // verify proper bitlen - if rsaKey.N.BitLen() != 1024 && rsaKey.N.BitLen() != 2048 { - 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 supported curve name + if !slices.Contains(supportedECDSACurves, pkcs8Key.Curve.Params().Name) { + return nil, errKeyWrongType + } + + // good to go + privateKey = pkcs8Key default: - return nil, errPemKeyWrongType + return nil, errKeyWrongType } default: @@ -86,12 +116,12 @@ func pemKeyDecode(keyPem []byte) (*rsa.PrivateKey, error) { } // if rsaKey is nil somehow, error - if rsaKey == nil { + if reflect.ValueOf(privateKey).IsNil() { return nil, errors.New("pkcs15: pem key: rsa key unexpectedly nil (report bug to project repo)") } // success! - return rsaKey, nil + return privateKey, nil } // pemCertDecode attempts to decode a pem encoded byte slice and then attempts diff --git a/pkg/pkcs15/pem_parse.go b/pkg/pkcs15/pem_parse.go index dcd899a..19e44f1 100644 --- a/pkg/pkcs15/pem_parse.go +++ b/pkg/pkcs15/pem_parse.go @@ -1,6 +1,8 @@ package pkcs15 import ( + "crypto" + "crypto/ecdsa" "crypto/rsa" "crypto/x509" ) @@ -8,8 +10,61 @@ import ( // pkcs15KeyCert holds the data for a key and certificate pair; it provides // various methods to transform pkcs15 data type pkcs15KeyCert struct { - key *rsa.PrivateKey + 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 +) + +// 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,10 +82,17 @@ func ParsePEMToPKCS15(keyPem, certPem []byte) (*pkcs15KeyCert, error) { return nil, err } + // create p15 struct p15 := &pkcs15KeyCert{ key: key, 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 a7a9fef..0c2214d 100644 --- a/pkg/pkcs15/pem_to_p15.go +++ b/pkg/pkcs15/pem_to_p15.go @@ -2,6 +2,10 @@ package pkcs15 import ( "apc-p15-tool/pkg/tools/asn1obj" + "crypto/ecdsa" + "crypto/rsa" + "encoding/asn1" + "fmt" "math/big" ) @@ -9,100 +13,89 @@ const ( apcKeyLabel = "Private key" ) -// ToP15File turns the key and cert into a properly formatted and encoded -// p15 file -func (p15 *pkcs15KeyCert) ToP15File() ([]byte, error) { - // private key object - pkey, err := p15.toP15PrivateKey() +// 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() (keyCert []byte, err error) { + // encrypted envelope is required + err = p15.computeEncryptedKeyEnvelope() if err != nil { return nil, err } - cert, err := p15.toP15Cert() - if err != nil { - return nil, err - } + // create private key object + var privKeyObj []byte - // ContentInfo - p15File := 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{ + switch p15.key.(type) { + case *rsa.PrivateKey: + // private key object + privKeyObj = asn1obj.Sequence([][]byte{ - asn1obj.Integer(big.NewInt(0)), + // commonObjectAttributes - Label asn1obj.Sequence([][]byte{ - asn1obj.ExplicitCompound(0, [][]byte{ - asn1obj.ExplicitCompound(0, [][]byte{ - pkey, - }), - }), - asn1obj.ExplicitCompound(4, [][]byte{ - asn1obj.ExplicitCompound(0, [][]byte{ - cert, + 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, }), }), }), - }), - }), - }) + }) - return p15File, nil -} + 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, + }), + }), + }), + }) -// toP15PrivateKey creates the encoded private key. it is broken our from the larger p15 -// function for readability -// NOTE: Do not use this to try and turn just a private key into a p15, the format isn't -// quite the same. -func (p15 *pkcs15KeyCert) toP15PrivateKey() ([]byte, error) { - // rsa encrypted key in encrypted envelope - envelope, err := p15.encryptedKeyEnvelope() - if err != nil { - return nil, err + default: + // bad key type + return nil, errKeyWrongType } - // key object - key := 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{ - asn1obj.Sequence([][]byte{ - // AuthEnvelopedData Type ([4]) - asn1obj.ExplicitCompound(4, [][]byte{ - envelope, - }), - }), - }), - }) - - return key, nil -} - -// toP15Cert creates the encoded certificate. it is broken our from the larger p15 -// function for readability -// NOTE: Do not use this to try and turn just a cert into a p15. I don't believe, -// such a thing is permissible under the spec. -func (p15 *pkcs15KeyCert) toP15Cert() ([]byte, error) { // cert object - cert := asn1obj.Sequence([][]byte{ + certObj := asn1obj.Sequence([][]byte{ // commonObjectAttributes - Label asn1obj.Sequence([][]byte{ asn1obj.UTF8String(apcKeyLabel), @@ -116,6 +109,7 @@ func (p15 *pkcs15KeyCert) toP15Cert() ([]byte, error) { p15.keyIdInt3(), p15.keyIdInt6(), p15.keyIdInt7(), + // 8 & 9 will return nil for EC keys (effectively omitting them) p15.keyIdInt8(), p15.keyIdInt9(), }), @@ -134,5 +128,246 @@ func (p15 *pkcs15KeyCert) toP15Cert() ([]byte, error) { }), }) - return cert, nil + // build the object + + // ContentInfo + keyCert = 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)), + asn1obj.Sequence([][]byte{ + asn1obj.ExplicitCompound(0, [][]byte{ + asn1obj.ExplicitCompound(0, [][]byte{ + privKeyObj, + }), + }), + asn1obj.ExplicitCompound(4, [][]byte{ + asn1obj.ExplicitCompound(0, [][]byte{ + certObj, + }), + }), + }), + }), + }), + }) + + return keyCert, nil +} + +// 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() (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: + // 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.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{ + 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: + // bad key type + return nil, errKeyWrongType + } + + // assemble complete object + 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)), + asn1obj.Sequence([][]byte{ + // [0] Private Keys + asn1obj.ExplicitCompound(0, [][]byte{ + asn1obj.ExplicitCompound(0, [][]byte{ + privKeyObj, + }), + }), + // [1] Public Keys + asn1obj.ExplicitCompound(1, [][]byte{ + asn1obj.ExplicitCompound(0, [][]byte{ + pubKeyObj, + }), + }), + }), + }), + }), + }) + + return key, nil } diff --git a/pkg/pkcs15/private_key.go b/pkg/pkcs15/private_key.go index 8c6b0fb..59b538e 100644 --- a/pkg/pkcs15/private_key.go +++ b/pkg/pkcs15/private_key.go @@ -1,24 +1,41 @@ package pkcs15 -import "apc-p15-tool/pkg/tools/asn1obj" +import ( + "apc-p15-tool/pkg/tools/asn1obj" + "crypto/ecdsa" + "crypto/rsa" +) // privateKeyObject returns the ASN.1 representation of a private key func (p15 *pkcs15KeyCert) privateKeyObject() []byte { - // ensure all expected vals are available - p15.key.Precompute() + var privKeyObj []byte - pkey := asn1obj.Sequence([][]byte{ - // P - asn1obj.IntegerExplicitValue(3, p15.key.Primes[0]), - // Q - asn1obj.IntegerExplicitValue(4, p15.key.Primes[1]), - // Dp - asn1obj.IntegerExplicitValue(5, p15.key.Precomputed.Dp), - // Dq - asn1obj.IntegerExplicitValue(6, p15.key.Precomputed.Dq), - // Qinv - asn1obj.IntegerExplicitValue(7, p15.key.Precomputed.Qinv), - }) + switch privKey := p15.key.(type) { + case *rsa.PrivateKey: + privKey.Precompute() - return pkey + // ensure all expected vals are available + privKeyObj = asn1obj.Sequence([][]byte{ + // P + asn1obj.IntegerExplicitValue(3, privKey.Primes[0]), + // Q + asn1obj.IntegerExplicitValue(4, privKey.Primes[1]), + // Dp + asn1obj.IntegerExplicitValue(5, privKey.Precomputed.Dp), + // Dq + asn1obj.IntegerExplicitValue(6, privKey.Precomputed.Dq), + // Qinv + asn1obj.IntegerExplicitValue(7, privKey.Precomputed.Qinv), + }) + + case *ecdsa.PrivateKey: + // Only private piece is the integer D + privKeyObj = asn1obj.Integer(privKey.D) + + default: + // 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