diff --git a/.github/workflows/build_releases.yml b/.github/workflows/build_releases.yml
index b7b329e..c6e902a 100644
--- a/.github/workflows/build_releases.yml
+++ b/.github/workflows/build_releases.yml
@@ -8,40 +8,10 @@ on:
env:
GITHUB_REF: ${{ github.ref }}
- GO_VERSION: '1.21.6'
jobs:
- build-common:
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout Main Repo
- uses: actions/checkout@v4
- with:
- repository: gregtwallace/apc-p15-tool
- ref: ${{ env.GITHUB_REF }}
- fetch-depth: 0
-
- - name: Save README
- uses: actions/upload-artifact@v4
- with:
- name: README.md
- path: ./README.md
-
- - name: Save LICENSE
- uses: actions/upload-artifact@v4
- with:
- name: LICENSE.md
- path: ./LICENSE.md
-
- - name: Save CHANGELOG
- uses: actions/upload-artifact@v4
- with:
- name: CHANGELOG.md
- path: ./CHANGELOG.md
-
- build-linux-arm64:
- runs-on: ubuntu-latest
+ build-all:
+ runs-on: ubuntu-24.04
steps:
- name: Checkout Repo
uses: actions/checkout@v4
@@ -50,255 +20,17 @@ jobs:
ref: ${{ env.GITHUB_REF }}
fetch-depth: 0
- - name: Update apt
- run: sudo apt update
-
- - name: Install cross-compiler for linux/arm64
- run: sudo apt-get -y install gcc-aarch64-linux-gnu
-
- name: Set up Go
uses: actions/setup-go@v5
with:
- go-version: '${{ env.GO_VERSION }}'
+ go-version-file: 'go.mod'
- - name: Build Tool
- run: go build -o ./apc-p15-tool -v ./cmd/tool
- env:
- GOOS: linux
- GOARCH: arm64
- CC: aarch64-linux-gnu-gcc
- CGO_ENABLED: 0
+ - name: Build All
+ run: |
+ python ./build_release.py
- - name: Save Compiled Binary
+ - name: Save Zip of all targets
uses: actions/upload-artifact@v4
with:
- name: apc-p15-tool-linux-arm64
- path: ./apc-p15-tool
-
- - name: Build Install Only
- run: go build -o ./apc-p15-install -v ./cmd/install_only
- env:
- GOOS: linux
- GOARCH: arm64
- CC: aarch64-linux-gnu-gcc
- CGO_ENABLED: 0
-
- - name: Save Compiled Binary
- uses: actions/upload-artifact@v4
- with:
- name: apc-p15-install-linux-arm64
- path: ./apc-p15-install
-
- build-linux-amd64:
- runs-on: ubuntu-latest
- steps:
- - name: Checkout Backend Repo
- uses: actions/checkout@v4
- with:
- repository: gregtwallace/apc-p15-tool
- ref: ${{ env.GITHUB_REF }}
- fetch-depth: 0
-
- - name: Set up Go
- uses: actions/setup-go@v5
- with:
- go-version: '${{ env.GO_VERSION }}'
-
- - name: Build Tool
- run: go build -o ./apc-p15-tool -v ./cmd/tool
- env:
- GOOS: linux
- GOARCH: amd64
- CGO_ENABLED: 0
-
- - name: Save Compiled Binary
- uses: actions/upload-artifact@v4
- with:
- name: apc-p15-tool-linux-amd64
- path: ./apc-p15-tool
-
- - name: Build Install Only
- run: go build -o ./apc-p15-install -v ./cmd/install_only
- env:
- GOOS: linux
- GOARCH: amd64
- CGO_ENABLED: 0
-
- - name: Save Compiled Binary
- uses: actions/upload-artifact@v4
- with:
- name: apc-p15-install-linux-amd64
- path: ./apc-p15-install
-
- build-windows-amd64:
- runs-on: windows-latest
- steps:
- - name: Checkout Backend Repo
- uses: actions/checkout@v4
- with:
- repository: gregtwallace/apc-p15-tool
- ref: ${{ env.GITHUB_REF }}
- fetch-depth: 0
-
- - name: Set up Go
- uses: actions/setup-go@v5
- with:
- go-version: '${{ env.GO_VERSION }}'
-
- - name: Build Tool
- run: go build -o ./apc-p15-tool.exe -v ./cmd/tool
- env:
- GOOS: windows
- GOARCH: amd64
- CGO_ENABLED: 0
-
- - name: Save Compiled Binary
- uses: actions/upload-artifact@v4
- with:
- name: apc-p15-tool-windows-amd64
- path: ./apc-p15-tool.exe
-
- - name: Build Install Only
- run: go build -o ./apc-p15-install.exe -v ./cmd/install_only
- env:
- GOOS: windows
- GOARCH: amd64
- CGO_ENABLED: 0
-
- - name: Save Compiled Binary
- uses: actions/upload-artifact@v4
- with:
- name: apc-p15-install-windows-amd64
- path: ./apc-p15-install.exe
-
- release-file-linux-arm64:
- needs: [build-common, build-linux-arm64]
- runs-on: ubuntu-latest
-
- steps:
- - name: Make release directory
- run: mkdir ./release
-
- - name: Download Tool Binary
- uses: actions/download-artifact@v4
- with:
- name: apc-p15-tool-linux-arm64
- path: ./release
-
- - name: Download Install Binary
- uses: actions/download-artifact@v4
- with:
- name: apc-p15-install-linux-arm64
- path: ./release
-
- - name: Download README
- uses: actions/download-artifact@v4
- with:
- name: README.md
- path: ./release
-
- - name: Download LICENSE
- uses: actions/download-artifact@v4
- with:
- name: LICENSE.md
- path: ./release
-
- - name: Download CHANGELOG
- uses: actions/download-artifact@v4
- with:
- name: CHANGELOG.md
- path: ./release
-
- - name: Save Release
- uses: actions/upload-artifact@v4
- with:
- name: apc-p15-tool_linux_arm64
- path: ./release
-
- release-file-linux-amd64:
- needs: [build-common, build-linux-amd64]
- runs-on: ubuntu-latest
-
- steps:
- - name: Make release directory
- run: mkdir ./release
-
- - name: Download Tool Binary
- uses: actions/download-artifact@v4
- with:
- name: apc-p15-tool-linux-amd64
- path: ./release
-
- - name: Download Install Binary
- uses: actions/download-artifact@v4
- with:
- name: apc-p15-install-linux-amd64
- path: ./release
-
- - name: Download README
- uses: actions/download-artifact@v4
- with:
- name: README.md
- path: ./release
-
- - name: Download LICENSE
- uses: actions/download-artifact@v4
- with:
- name: LICENSE.md
- path: ./release
-
- - name: Download CHANGELOG
- uses: actions/download-artifact@v4
- with:
- name: CHANGELOG.md
- path: ./release
-
- - name: Save Release
- uses: actions/upload-artifact@v4
- with:
- name: apc-p15-tool_linux_amd64
- path: ./release
-
- release-file-windows-amd64:
- needs: [build-common, build-windows-amd64]
- runs-on: ubuntu-latest
-
- steps:
- - name: Make release directory
- run: mkdir ./release
-
- - name: Download Tool Binary
- uses: actions/download-artifact@v4
- with:
- name: apc-p15-tool-windows-amd64
- path: ./release
-
- - name: Download Install Binary
- uses: actions/download-artifact@v4
- with:
- name: apc-p15-install-windows-amd64
- path: ./release
-
- - name: Download README
- uses: actions/download-artifact@v4
- with:
- name: README.md
- path: ./release
-
- - name: Download LICENSE
- uses: actions/download-artifact@v4
- with:
- name: LICENSE.md
- path: ./release
-
- - name: Download CHANGELOG
- uses: actions/download-artifact@v4
- with:
- name: CHANGELOG.md
- path: ./release
-
- - name: Save Release
- uses: actions/upload-artifact@v4
- with:
- name: apc-p15-tool_windows_amd64
- path: ./release
+ name: apc-p15-tool-release
+ path: ./_out/_release
diff --git a/.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 955694c..176d776 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,29 +1,182 @@
# APC P15 Tool Changelog
-## [v0.3.3] - 2024.02.04
+## [v1.3.1] - 2025-07-17
+
+- Add MD5 hash of the SSH key to the error output when SSH key verification
+ fails. MD5 hash cannot be used as the fingerprint value, but it can help
+ verify the line is currently secure before copying the sha256 value.
+
+
+## [v1.3.0] - 2025-06-23
+
+This release attempts to detect and warn of possible incompatibilies with a
+spcecified certificate. NMCs do not warn or error when a bad file is installed,
+instead they silently fail and generally just generate a new self-signed
+certificate. This release checks some properties of the specified certificate
+and produces warning messages that can be referenced if the cert installation
+appears to work but ultimately doesn't prododuce the expected result.
+
+- Add warnings based on key type, signature algorithm, validity dates, and
+ extensions.
+- Minor lint.
+
+
+## [v1.2.3] - 2025-06-19
+
+Minor updates to the application. Large updates to the build process to
+improve building, releasing, and maintainability.
+
+- Go updated to 1.24.4 and all dependencies updated.
+- Added FreeBSD arm64 and amd64 builds.
+- Build process overhauled for simplicity. Build is now OS agnostic. PowerShell
+ script was removed and replaced with a python script.
+- Build instructions added to README.
+- GitHub build action now only runs in one Ubuntu container and cross-compiles.
+- Release windows and macos as zip files and all others as gztar.
+- Add file permissions for non-windows and non-macos releases.
+
+
+## [v1.2.2] - 2025-04-22
+
+All dependencies updated.
+
+Add darwin arm64 and amd64 builds.
+
+
+## [v1.2.1] - 2025-03-17
+
+Fix time check for UPS when it is set to GMT timezone.
+
+All dependencies updated.
+
+
+## [v1.2.0] - 2025-01-27
+
+Add a new feature to `install` that checks the time of the UPS to confirm
+it is accurate. A log message is added that advises either way. Even if
+the check fails, the install still proceeds with attempting to install
+the new certificate.
+
+Dependencies were also all updated.
+
+
+## [v1.1.0] - 2024-09-17
+
+> [!IMPORTANT]
+> The flag `apchost` on the `install` command has been renamed to
+> `hostname`. This flag should contain the hostname only. If a non-
+> default SSH port is needed, specify it in the `sshport` flag.
+
+This version brings support for for RSA 4,092 bit and EC keys. These
+keys are only compatible with NMC3 running newer firmwares. To know
+if your firmware is new enough, SSH into your UPS and type `ssh` and enter.
+If the UPS responds `Command Not Found` the firmware is too old or
+otherwise incompatible.
+
+This version also adds a post `install` check that connects to the web
+ui and verifies the certificate served is the expected one. You can
+specify a non standard ssl port with the `sslport` flag or skip the check
+entirely with the `skipverify` flag.
+
+
+## [v1.0.0] - 2024-07-01
+
+First official stable release.
+
+Fixes Go version in Github action.
+
+
+## [v0.5.3] - 2024-06-24
+
+Add 3,072 bit RSA key support.
+
+
+## [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.
+
+
+## [v0.4.1] - 2024-03-06
+
+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
+my NMC2, but I suspect some might need it to get the new certificate
+to actually load.
+
+
+## [v0.3.3] - 2024-02-04
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.
-## [v0.3.2] - 2024.02.04
+## [v0.3.2] - 2024-02-04
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.
-## [v0.3.1] - 2024.02.03
+## [v0.3.1] - 2024-02-03
Fixes debug logging always being on. App now accurately reflects
the state of the --debug flag.
-## [v0.3.0] - 2024.02.03
+## [v0.3.0] - 2024-02-03
Initial release.
diff --git a/README.md b/README.md
index 10ea261..9286344 100644
--- a/README.md
+++ b/README.md
@@ -1,30 +1,145 @@
# 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).
-## Compatibility Notice
+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.
-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.
+## Background
-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:
+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.
+
+### Key Types and Sizes
+
+Ensure you select an appropriate key!
+
+NMC2 is extremely picky about the key type and size it supports. NMC3 is a bit
+more flexible. Beware, some ACME clients will generate an ECDSA key by default
+which is NOT supported by NMC2.
+
+NMC2:
+- RSA 1,024, 2,048, 3,072* bit lengths.
+
+NMC3*:
+- RSA 1,024, 2,048, 3,072, and 4,092 bit lengths.
+- ECDSA curves P-256, P-384, and P-521.
+
+\* 3,072 bit length is not officially supported by my NMC2, but appears to work
+ fine.
+
+\* The additional key types supported by NMC3 require newer firmware on the
+ device. I am unsure what the version cutoff is, but you can check support
+ by connecting to the UPS via SSH and typing `ssl`. If `Command Not Found`
+ is returned, the firmware is too old and only the key types listed under
+ NMC2 will work.
+
+1,024 bit RSA is no longer considered completely secure; avoid keys of
+this size if possible. Most (all?) public ACME services won't accept keys
+of this size anyway.
+
+### Compatibility Notice
+
+Both NMC2 and NMC3 devices should be fully supported. However, I have one
+NMC2 device in a home lab and have no way to guarantee success in all cases.
+
+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.
+Generally, if there is a compatibility issue, there is a good chance you will
+not see an error. Rather, the NMC will silently fail and you'll only know
+something went wrong because the NMC's certificate didn't update, or it regenerated
+a self-signed certificate that you'll see upon your next connection attempt.
+I've tried to add some `WARNING` messages to the tool to indicate what might
+be going wrong, but the list is definitely not exhaustive.
-In particular, if you are experiencing `ssh: handshake failed:` please run
-`ssh -vv myups.example.com` and include the `peer server KEXINIT proposal`
-in your issue. For example:
+### Troubleshooting
+
+Suggested troubleshooting steps:
+- Review the `Key Types and Sizes` and `Compatibility Notice` sections of this
+ README.
+- Update your NMC's firmware to the latest version.
+- Read this tool's output, look specifically for any `WARNING` messages and
+ adjust your certificate accordingly.
+- Test using an RSA 2048 bit key to obtain a certificate from Let's Encrypt.
+ Their certificates are known to work with NMC.
+- Use the official NMC Security Wizard to verify you can create a working
+ certificate and load it into your NMC. If the official tool does not work
+ switching to this tool won't help.
+
+If you have tried all of these steps and are still experiencing a problem,
+you may open an Issue on GitHub.
+
+Include:
+- The full command you are running that is causing the problem.
+- The full log of this tool's output when you run the command. Append the
+ `--debug` flag to your command to get the debug output.
+
+Keep in mind, I am one person with one specific hardware setup. I may not
+be able to help you.
+
+#### NMC3 Install `ssh: parse error in message type 53` Error
+
+Configuring a `System Message` on an NMC3 breaks the install function. I do
+not have an NMC3 and after doing some code review it is highly unlikely I'll
+be able to fix this. Don't use a `System Message` if the install feature is
+important to you.
+
+see: https://github.com/gregtwallace/apc-p15-tool/issues/14
+
+#### Install `ssh: handshake failed` Error
+
+If you are experiencing `ssh: handshake failed:` first try
+using the `--insecurecipher` flag. If this works, you should upgrade your
+NMC to a newer firmware which includes secure ciphers. You should NOT automate
+your environment using this flag as SSH over these ciphers is broken and
+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 +174,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 +203,26 @@ 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.
-
+
+
+## Building
+
+Python3, Go, and git all must be installed to run the build script.
+
+Once the dependencies are installed, clone this repo and run
+`python build_release.py`. If you only want to build for certain OS or
+ARCH targets, edit the `targets` array in the `build_release.py` file
+before running it.
+
+## Links
+
+@Owl-Tec's write up using this tool with ACDS:
+https://owltec.ca/Windows+Server/Deploying+An+Internal+HTTPS+Certificate+for+a+UPS+APC+with+ADCS+(Active+Directory+Certificate+Services)+with+APC+P15+Tool
## Thanks
diff --git a/build.ps1 b/build.ps1
deleted file mode 100644
index 3fee1f4..0000000
--- a/build.ps1
+++ /dev/null
@@ -1,36 +0,0 @@
-# Parent dir is root
-$scriptDir = Get-Location
-$outDir = Join-Path -Path $scriptDir -ChildPath "/_out"
-
-# Windows x64
-$env:GOARCH = "amd64"
-$env:GOOS = "windows"
-$env:CGO_ENABLED = 0
-go build -o $outDir/apc-p15-tool-amd64.exe ./cmd/tool
-
-$env:GOARCH = "amd64"
-$env:GOOS = "windows"
-$env:CGO_ENABLED = 0
-go build -o $outDir/apc-p15-install-amd64.exe ./cmd/tool
-
-# Linux x64
-$env:GOARCH = "amd64"
-$env:GOOS = "linux"
-$env:CGO_ENABLED = 0
-go build -o $outDir/apc-p15-tool-amd64 ./cmd/tool
-
-$env:GOARCH = "amd64"
-$env:GOOS = "linux"
-$env:CGO_ENABLED = 0
-go build -o $outDir/apc-p15-install-amd64 ./cmd/install_only
-
-# Linux arm64
-$env:GOARCH = "arm64"
-$env:GOOS = "linux"
-$env:CGO_ENABLED = 0
-go build -o $outDir/apc-p15-tool-arm64 ./cmd/tool
-
-$env:GOARCH = "arm64"
-$env:GOOS = "linux"
-$env:CGO_ENABLED = 0
-go build -o $outDir/apc-p15-install-arm64 ./cmd/install_only
diff --git a/build_release.py b/build_release.py
new file mode 100644
index 0000000..d3892e7
--- /dev/null
+++ b/build_release.py
@@ -0,0 +1,89 @@
+#!/usr/bin/env python3
+import os.path
+import shutil
+import subprocess
+import tarfile
+
+# Configuration
+# output path (relative to this script)
+outRelativeDir = "_out"
+
+# target strings must be in the format:
+# `GOOS_GOARCH`
+# see: https://github.com/golang/go/blob/master/src/internal/syslist/syslist.go
+# or unofficially: https://gist.github.com/asukakenji/f15ba7e588ac42795f421b48b8aede63
+targets = [
+ "windows_amd64",
+ "linux_amd64",
+ "linux_arm64",
+ "darwin_amd64",
+ "darwin_arm64",
+ "freebsd_amd64",
+ "freebsd_arm64",
+]
+
+###
+
+# Script
+# relative dir is root
+scriptDir = dirname = os.path.dirname(__file__)
+outBaseDir = os.path.join(scriptDir, outRelativeDir)
+releaseDir = os.path.join(outBaseDir, "_release")
+
+# recreate paths
+if os.path.exists(outBaseDir):
+ shutil.rmtree(outBaseDir)
+os.makedirs(outBaseDir)
+os.makedirs(releaseDir)
+
+# get version number / tag
+gitTag = subprocess.check_output(["git", "describe", "--tags", "--abbrev=0"]).decode('utf-8').strip()
+
+# loop through and build all targets
+for target in targets:
+ # environment vars
+ split = target.split("_")
+ GOOS = split[0]
+ GOARCH = split[1]
+ os.environ["GOOS"] = GOOS
+ os.environ["GOARCH"] = GOARCH
+ os.environ["CGO_ENABLED"] = "0"
+
+ # send build product to GOOS_GOARCH subfolders
+ targetOutDir = os.path.join(outBaseDir, target)
+ if not os.path.exists(targetOutDir):
+ os.makedirs(targetOutDir)
+
+ # special case for windows to add file extensions
+ extension = ""
+ if GOOS.lower() == "windows":
+ extension = ".exe"
+
+ # build binary and install only binary
+ subprocess.run(["go", "build", "-o", f"{targetOutDir}/apc-p15-tool{extension}", "./cmd/tool"])
+ subprocess.run(["go", "build", "-o", f"{targetOutDir}/apc-p15-install{extension}", "./cmd/install_only"])
+
+ # copy other important files for release
+ shutil.copy("README.md", targetOutDir)
+ shutil.copy("CHANGELOG.md", targetOutDir)
+ shutil.copy("LICENSE.md", targetOutDir)
+
+ # compress release file
+ # special case for windows & mac to use zip format
+ if GOOS.lower() == "windows" or GOOS.lower() == "darwin":
+ shutil.make_archive(f"{releaseDir}/apc-p15-tool-{gitTag}_{target}", "zip", targetOutDir)
+ else:
+ # for others, use gztar and set permissions on the files
+
+ # filter for setting permissions
+ def set_permissions(tarinfo):
+ if tarinfo.name == "apc-p15-tool" or tarinfo.name == "apc-p15-install":
+ tarinfo.mode = 0o0755
+ else:
+ tarinfo.mode = 0o0644
+ return tarinfo
+
+ # make tar
+ with tarfile.open(f"{releaseDir}/apc-p15-tool-{gitTag}_{target}.tar.gz", "w:gz") as tar:
+ for file in os.listdir(targetOutDir):
+ tar.add(os.path.join(targetOutDir, file), arcname=file, recursive=False, filter=set_permissions)
diff --git a/go.mod b/go.mod
index 1df6fbe..4848582 100644
--- a/go.mod
+++ b/go.mod
@@ -1,19 +1,21 @@
module apc-p15-tool
-go 1.21
+go 1.24.4
require (
github.com/peterbourgon/ff/v4 v4.0.0-alpha.4
- github.com/sigurn/crc16 v0.0.0-20211026045750-20ab5afb07e3
- golang.org/x/crypto v0.18.0
+ github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1
+ golang.org/x/crypto v0.39.0
)
-require golang.org/x/sys v0.16.0 // indirect
+require golang.org/x/sys v0.33.0 // indirect
replace apc-p15-tool/cmd/install_only => /cmd/install_only
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..cd5913b 100644
--- a/go.sum
+++ b/go.sum
@@ -2,13 +2,13 @@ github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N
github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/peterbourgon/ff/v4 v4.0.0-alpha.4 h1:aiqS8aBlF9PsAKeMddMSfbwp3smONCn3UO8QfUg0Z7Y=
github.com/peterbourgon/ff/v4 v4.0.0-alpha.4/go.mod h1:H/13DK46DKXy7EaIxPhk2Y0EC8aubKm35nBjBe8AAGc=
-github.com/sigurn/crc16 v0.0.0-20211026045750-20ab5afb07e3 h1:aQKxg3+2p+IFXXg97McgDGT5zcMrQoi0EICZs8Pgchs=
-github.com/sigurn/crc16 v0.0.0-20211026045750-20ab5afb07e3/go.mod h1:9/etS5gpQq9BJsJMWg1wpLbfuSnkm8dPF6FdW2JXVhA=
-golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
-golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
-golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
-golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE=
-golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
+github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1 h1:NVK+OqnavpyFmUiKfUMHrpvbCi2VFoWTrcpI7aDaJ2I=
+github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1/go.mod h1:9/etS5gpQq9BJsJMWg1wpLbfuSnkm8dPF6FdW2JXVhA=
+golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
+golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
+golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
+golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
+golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
diff --git a/img/apc-p15-tool.png b/img/apc-p15-tool.png
index 807fb84..c537585 100644
Binary files a/img/apc-p15-tool.png and b/img/apc-p15-tool.png differ
diff --git a/pkg/apcssh/client.go b/pkg/apcssh/client.go
new file mode 100644
index 0000000..7ed5f34
--- /dev/null
+++ b/pkg/apcssh/client.go
@@ -0,0 +1,157 @@
+package apcssh
+
+import (
+ "bytes"
+ "crypto/md5"
+ "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)
+
+ // convert to expected format for comparison
+ actualHashB64 := base64.RawStdEncoding.EncodeToString(actualHash)
+ actualHashHex := hex.EncodeToString(actualHash)
+
+ // check for fingerprint match (b64 or hex)
+ if actualHashB64 != cfg.ServerFingerprint && actualHashHex != cfg.ServerFingerprint {
+ // calculate server's key's MD5
+ // MD5 CANNOT be used in the config as collisions are too common, however, this
+ // is the value shown in the NMC web interface, so it may be useful to users for
+ // some level of assurance
+ hasher = md5.New()
+ _, err = hasher.Write(key.Marshal())
+ if err != nil {
+ return err
+ }
+ md5ActualHash := hasher.Sum(nil)
+
+ md5ActualHashHex := string(hex.EncodeToString(md5ActualHash))
+
+ // add colons for copy/paste convenience since they exist in the webui
+ var buffer bytes.Buffer
+ n_1 := 1
+ l_1 := len(md5ActualHashHex) - 1
+ for i, rune := range md5ActualHashHex {
+ buffer.WriteRune(rune)
+ if i%2 == n_1 && i != l_1 {
+ buffer.WriteRune(':')
+ }
+ }
+ md5ActualHashHex = buffer.String()
+
+ // return detailed info for convenience and debugging
+ return fmt.Errorf("apcssh: server returned wrong sha256 fingerprint (b64: %s ; hex: %s ; "+
+ "md5 hex is: %s , but is not acceptable in the fingerprint parameter)", actualHashB64, actualHashHex, md5ActualHashHex)
+ }
+
+ 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..24fdd64
--- /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 len(keyP15) <= 0 || 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 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 337623d..e35a7f0 100644
--- a/pkg/app/app.go
+++ b/pkg/app/app.go
@@ -12,7 +12,7 @@ import (
)
const (
- appVersion = "0.3.3"
+ appVersion = "1.3.1"
)
// struct for receivers to use common app pieces
diff --git a/pkg/app/cmd_create.go b/pkg/app/cmd_create.go
index e0e0291..8791a81 100644
--- a/pkg/app/cmd_create.go
+++ b/pkg/app/cmd_create.go
@@ -2,11 +2,15 @@ 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
@@ -23,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 fd60efc..d94c00c 100644
--- a/pkg/app/cmd_install.go
+++ b/pkg/app/cmd_install.go
@@ -1,18 +1,19 @@
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 {
@@ -42,110 +43,113 @@ 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: scpTimeout,
+ // 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)
}
+ app.stdLogger.Println("install: connected to ups ssh, installing ssl key and cert...")
- // send file to UPS
- err = scpSendFileToUPS(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))
}
- // done
- app.stdLogger.Printf("install: apc p15 file installed on %s", *app.config.install.hostAndPort)
+ // 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.hostname)
+
+ // restart UPS webUI
+ if app.config.install.restartWebUI != nil && *app.config.install.restartWebUI {
+ app.stdLogger.Println("install: sending restart command")
+
+ err = client.RestartWebUI()
+ if err != nil {
+ return fmt.Errorf("install: failed to send webui restart command (%w)", err)
+ }
+
+ app.stdLogger.Println("install: sent webui restart command")
+ }
+
+ // check the new certificate is installed
+ if app.config.install.skipVerify != nil && !*app.config.install.skipVerify &&
+ app.config.install.webUISSLPort != nil && *app.config.install.webUISSLPort != 0 {
+
+ app.stdLogger.Println("install: attempting to verify certificate install...")
+
+ // sleep for UPS to finish anything it might be doing
+ time.Sleep(5 * time.Second)
+
+ // if UPS web UI was restarted, sleep longer
+ if app.config.install.restartWebUI != nil && *app.config.install.restartWebUI {
+ app.stdLogger.Println("install: waiting for ups webui restart...")
+ time.Sleep(25 * time.Second)
+ }
+
+ // connect to the web UI to get the current certificate
+ conf := &tls.Config{
+ InsecureSkipVerify: true,
+ }
+ conn, err := tls.Dial("tcp", *app.config.install.hostname+":"+strconv.Itoa(*app.config.install.webUISSLPort), conf)
+ if err != nil {
+ return fmt.Errorf("install: failed to dial webui for verification (%s)", err)
+ }
+ defer conn.Close()
+
+ // get top cert
+ leafCert := conn.ConnectionState().PeerCertificates[0]
+ if leafCert == nil {
+ return fmt.Errorf("install: failed to get web ui leaf cert for verification (%s)", err)
+ }
+
+ // convert pem to DER for comparison
+ pemBlock, _ := pem.Decode(certPem)
+
+ // verify cert is the correct one
+ certVerified := bytes.Equal(leafCert.Raw, pemBlock.Bytes)
+ if !certVerified {
+ return errors.New("install: web ui leaf cert does not match new cert (your cert may not be compatible with NMC; check for WARNINGs in this tool's output)")
+ }
+
+ app.stdLogger.Println("install: ups web ui cert verified")
+ }
return nil
}
diff --git a/pkg/app/config.go b/pkg/app/config.go
index ed6cd9a..7edf216 100644
--- a/pkg/app/config.go
+++ b/pkg/app/config.go
@@ -28,14 +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
}
}
@@ -55,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",
@@ -66,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",
@@ -85,19 +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 upload --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..f6a6094 100644
--- a/pkg/app/pem_to_p15.go
+++ b/pkg/app/pem_to_p15.go
@@ -2,37 +2,169 @@ package app
import (
"apc-p15-tool/pkg/pkcs15"
+ "crypto/x509"
+ "encoding/asn1"
"fmt"
+ "slices"
+ "time"
)
-// pemToAPCP15 reads the specified pem files and returns the apc p15 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
+}
+
+// known good signing algorithms
+var knownSupportedNMC2SigningAlgs = []x509.SignatureAlgorithm{
+ x509.SHA256WithRSA,
+}
+
+var knownSupportedNMC3SigningAlgs = append(knownSupportedNMC2SigningAlgs, []x509.SignatureAlgorithm{
+ x509.ECDSAWithSHA384,
+}...)
+
+// known supported cert extensions
+var knownSupportedCriticalOIDs = []asn1.ObjectIdentifier{
+ {2, 5, 29, 15}, // keyUsage
+ {2, 5, 29, 19}, // basicConstraints
+ {2, 5, 29, 17}, // subjectAltName
+}
+
+var knownSupportedOIDs = append(knownSupportedCriticalOIDs, []asn1.ObjectIdentifier{
+ {2, 5, 29, 37}, // extKeyUsage
+ {2, 5, 29, 14}, // subjectKeyIdentifier
+ {2, 5, 29, 35}, // authorityKeyIdentifier
+ {1, 3, 6, 1, 5, 5, 7, 1, 1}, // authorityInfoAccess
+ {2, 5, 29, 32}, // certificatePolicies
+ {1, 3, 6, 1, 4, 1, 11129, 2, 4, 2}, // googleSignedCertificateTimestamp
+ {2, 5, 29, 31}, // cRLDistributionPoints
+}...)
+
+// pemToAPCP15 reads the specified pem files and returns the apc p15 file(s). If the
+// key type of the key is not supported by NMC2, the combined key+cert file is not
+// generated and nil is returned instead for that file. If the key IS supported by
+// NMC2, the key+cert file is generated and the proper header is prepended.
+func (app *app) pemToAPCP15(keyPem, certPem []byte, parentCmdName string) (keyFile []byte, apcKeyCertFile []byte, err error) {
+ app.stdLogger.Printf("%s: making apc p15 file(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
+ nmc2KeyType := false
+ if slices.Contains(nmc2SupportedKeyTypes, p15.KeyType()) {
+ nmc2KeyType = true
+
+ app.stdLogger.Printf("%s: key type is supported by NMC2, generating p15 key+cert file content...", parentCmdName)
+
+ // make file bytes
+ keyCertFile, err := p15.ToP15KeyCert()
+ if err != nil {
+ return nil, nil, fmt.Errorf("%s: failed to make p15 key+cert file content (%w)", parentCmdName, err)
+ }
+
+ // make header for file bytes
+ apcHeader, err := makeFileHeader(keyCertFile)
+ if err != nil {
+ return nil, nil, fmt.Errorf("%s: failed to make p15 key+cert file header (%w)", parentCmdName, err)
+ }
+
+ // combine header with file
+ apcKeyCertFile = append(apcHeader, keyCertFile...)
}
- // combine header with file
- apcFile := append(apcHeader, p15File...)
+ // check various parts of cert and log compatibility warnings
+ warned := false
- app.stdLogger.Printf("%s: apc p15 file data succesfully generated", parentCmdName)
+ // key not supported for NMC2
+ if !nmc2KeyType {
+ app.stdLogger.Printf("WARNING: %s: key type is %s and is not supported by NMC2.", parentCmdName, p15.KeyType().String())
+ warned = true
+ }
- return apcFile, nil
+ // signature algorithm (see: https://github.com/gregtwallace/apc-p15-tool/issues/18)
+ if !nmc2KeyType {
+ // definitely not for NMC2
+ if !slices.Contains(knownSupportedNMC3SigningAlgs, p15.Cert.SignatureAlgorithm) {
+ app.stdLogger.Printf("WARNING: %s: Certificate signing algorithm is %s and it is not known if NMC3 supports this algorithm.", parentCmdName, p15.Cert.SignatureAlgorithm.String())
+ warned = true
+ }
+ } else {
+ // could be for either NMC2 or NMC3
+ if !slices.Contains(knownSupportedNMC2SigningAlgs, p15.Cert.SignatureAlgorithm) {
+ if !slices.Contains(knownSupportedNMC3SigningAlgs, p15.Cert.SignatureAlgorithm) {
+ // not in NMC2 or NMC3 list
+ app.stdLogger.Printf("WARNING: %s: Certificate signing algorithm is %s and is not supported by NMC2. It is also not known if NMC3 supports this algorithm.", parentCmdName, p15.Cert.SignatureAlgorithm.String())
+ } else {
+ // not in NMC2 list, but is in NMC3 list
+ app.stdLogger.Printf("WARNING: %s: Certificate signing algorithm is %s and it does not support NMC2.", parentCmdName, p15.Cert.SignatureAlgorithm.String())
+ }
+ warned = true
+ }
+ }
+
+ // check validity dates
+ if time.Now().Before(p15.Cert.NotBefore) {
+ app.stdLogger.Printf("WARNING: %s: Current time (%s) is before certificate's NotBefore time (%s).",
+ parentCmdName, time.Now().Format(timeLoggingFormat), p15.Cert.NotBefore.Format(timeLoggingFormat))
+ warned = true
+ }
+
+ if time.Now().After(p15.Cert.NotAfter) {
+ app.stdLogger.Printf("WARNING: %s: Current time (%s) is after certificate's NotAfter time (%s).",
+ parentCmdName, time.Now().Format(timeLoggingFormat), p15.Cert.NotAfter.Format(timeLoggingFormat))
+ warned = true
+ }
+
+ // check extensions against known working extensions
+ for _, extension := range p15.Cert.Extensions {
+ // critical or not?
+ okOIDs := knownSupportedCriticalOIDs
+ criticalLogMsg := "Critical "
+ if !extension.Critical {
+ okOIDs = knownSupportedOIDs
+ criticalLogMsg = ""
+ }
+
+ // validate OIDs
+ ok := false
+ for _, okOID := range okOIDs {
+ if okOID.Equal(extension.Id) {
+ ok = true
+ break
+ }
+ }
+
+ if !ok {
+ app.stdLogger.Printf("WARNING: %s: %sExtension %s may not be supported by NMC.", parentCmdName, criticalLogMsg, extension.Id.String())
+ }
+ }
+
+ // log a message about possible failure
+ if warned {
+ app.stdLogger.Printf("WARNING: %s: Possible certificate compatibility issues were detected. If the resulting p15 file "+
+ "does not work with your NMC (e.g., a self-signed certificate is regenerated after you try to install the p15), "+
+ "modify your certificate to resolve the warnings and try again.", parentCmdName)
+ }
+
+ // end compatibility warnings
+
+ app.stdLogger.Printf("%s: apc p15 file(s) data succesfully generated", parentCmdName)
+
+ return keyFile, apcKeyCertFile, nil
}
diff --git a/pkg/app/scp.go b/pkg/app/scp.go
deleted file mode 100644
index 9fe55e4..0000000
--- a/pkg/app/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 (
- scpP15Destination = "/ssl/defaultcert.p15"
- scpP15PermissionsStr = "0600"
-
- scpTimeout = 90 * time.Second
-)
-
-// scpSendFileToUPS 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 scpSendFileToUPS(client *ssh.Client, p15File []byte) error {
- // make session to use for SCP
- session, err := client.NewSession()
- if err != nil {
- return fmt.Errorf("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", scpP15Destination))
- 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("scp: failed to execute scp cmd (%w)", err)
- }
- if !ok {
- return errors.New("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("scp: failed to send scp cmd (bad remote response) (%w)", err)
- }
-
- // just file name (without path)
- filename := path.Base(scpP15Destination)
-
- // send file header
- _, err = fmt.Fprintln(w, "C"+scpP15PermissionsStr, len(p15File), filename)
- if err != nil {
- return fmt.Errorf("scp: failed to send file info (%w)", err)
- }
-
- err = scpCheckResponse(out)
- if err != nil {
- return fmt.Errorf("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("scp: failed to send file(%w)", err)
- }
-
- // send file end
- _, err = fmt.Fprint(w, "\x00")
- if err != nil {
- return fmt.Errorf("scp: failed to send final 00 byte (%w)", err)
- }
-
- err = scpCheckResponse(out)
- if err != nil {
- return fmt.Errorf("scp: failed to send file (bad remote response) (%w)", err)
- }
-
- // done
- return nil
-}
diff --git a/pkg/app/scp_response.go b/pkg/app/scp_response.go
deleted file mode 100644
index 9968366..0000000
--- a/pkg/app/scp_response.go
+++ /dev/null
@@ -1,34 +0,0 @@
-package app
-
-import (
- "bufio"
- "fmt"
- "io"
-)
-
-// 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("scp: 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("scp: failed to read output buffer (%w)", err)
- }
- }
-
- // if not 0 (aka OK)
- if responseType != 0 {
- return fmt.Errorf("scp: remote returned error (%d: %s)", responseType, message)
- }
-
- return nil
-}
diff --git a/pkg/pkcs15/encrypted_envelope.go b/pkg/pkcs15/encrypted_envelope.go
index b0eef9d..a6dc525 100644
--- a/pkg/pkcs15/encrypted_envelope.go
+++ b/pkg/pkcs15/encrypted_envelope.go
@@ -21,14 +21,19 @@ const (
apcKEKIterations = 5000
)
-// encryptedKeyEnvelope encrypts p15's rsa private key using the algorithms and
-// params expected in the APC file. Salt values are always random.
-func (p15 *pkcs15KeyCert) encryptedKeyEnvelope() ([]byte, error) {
+// encryptedKeyEnvelope encrypts p15's private key using the algorithms and
+// params expected in the APC file.
+func (p15 *pkcs15KeyCert) computeEncryptedKeyEnvelope() error {
+ // if computation already performed, this is a no-op (keep existing envelope)
+ if len(p15.envelopedPrivateKey) > 0 {
+ return nil
+ }
+
// calculate values for the object
kekSalt := make([]byte, 8)
_, err := rand.Read(kekSalt)
if err != nil {
- return nil, err
+ return err
}
// kek hash alg
@@ -42,7 +47,7 @@ func (p15 *pkcs15KeyCert) encryptedKeyEnvelope() ([]byte, error) {
// make DES cipher from KEK for CEK
cekDesCipher, err := des.NewTripleDESCipher(kek)
if err != nil {
- return nil, err
+ return err
}
// cek (16 bytes for authEnc128) -- see: rfc3211
@@ -50,7 +55,7 @@ func (p15 *pkcs15KeyCert) encryptedKeyEnvelope() ([]byte, error) {
cek := make([]byte, cekLen)
_, err = rand.Read(cek)
if err != nil {
- return nil, err
+ return err
}
// LEN + Check Val [3]
@@ -71,7 +76,7 @@ func (p15 *pkcs15KeyCert) encryptedKeyEnvelope() ([]byte, error) {
cekPadding := make([]byte, cekPadLen)
_, err = rand.Read(cekPadding)
if err != nil {
- return nil, err
+ return err
}
wrappedCEK = append(wrappedCEK, cekPadding...)
@@ -80,7 +85,7 @@ func (p15 *pkcs15KeyCert) encryptedKeyEnvelope() ([]byte, error) {
cekEncryptSalt := make([]byte, 8)
_, err = rand.Read(cekEncryptSalt)
if err != nil {
- return nil, err
+ return err
}
cekEncrypter := cipher.NewCBCEncrypter(cekDesCipher, cekEncryptSalt)
@@ -94,13 +99,13 @@ func (p15 *pkcs15KeyCert) encryptedKeyEnvelope() ([]byte, error) {
contentEncSalt := make([]byte, 8)
_, err = rand.Read(contentEncSalt)
if err != nil {
- return nil, err
+ return err
}
contentEncryptKey := pbkdf2.Key(cek, []byte("encryption"), 1, 24, sha1.New)
contentDesCipher, err := des.NewTripleDESCipher(contentEncryptKey)
if err != nil {
- return nil, err
+ return err
}
// envelope content (that will be encrypted)
@@ -120,9 +125,6 @@ func (p15 *pkcs15KeyCert) encryptedKeyEnvelope() ([]byte, error) {
encryptedContent := make([]byte, len(content))
contentEncrypter.CryptBlocks(encryptedContent, content)
- // encrypted content MAC
- macKey := pbkdf2.Key(cek, []byte("authentication"), 1, 32, sha1.New)
-
// data encryption alg block
encAlgObj := asn1obj.Sequence([][]byte{
// ContentEncryptionAlgorithmIdentifier
@@ -144,6 +146,9 @@ func (p15 *pkcs15KeyCert) encryptedKeyEnvelope() ([]byte, error) {
}),
})
+ // encrypted content MAC
+ macKey := pbkdf2.Key(cek, []byte("authentication"), 1, 32, sha1.New)
+
macHasher := hmac.New(sha256.New, macKey)
// the data the MAC covers is the algId header bytes + encrypted data bytes
hashMe := append(encAlgObj, encryptedContent...)
@@ -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..cac7301 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,25 +13,9 @@ 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)
+ _, err := hasher.Write(p15.Cert.RawSubjectPublicKeyInfo)
if err != nil {
panic(err)
}
@@ -60,9 +46,9 @@ func (p15 *pkcs15KeyCert) keyIdInt3() []byte {
// object to hash
hashObj := asn1obj.Sequence([][]byte{
// issuerDistinguishedName
- p15.cert.RawIssuer,
+ p15.Cert.RawIssuer,
// serialNumber
- asn1obj.Integer(p15.cert.SerialNumber),
+ asn1obj.Integer(p15.Cert.SerialNumber),
})
// SHA-1 Hash
@@ -88,7 +74,7 @@ func (p15 *pkcs15KeyCert) keyIdInt6() []byte {
// SHA-1 Hash
hasher := sha1.New()
- _, err := hasher.Write(p15.cert.RawIssuer)
+ _, err := hasher.Write(p15.Cert.RawIssuer)
if err != nil {
panic(err)
}
@@ -109,7 +95,7 @@ func (p15 *pkcs15KeyCert) keyIdInt7() []byte {
// SHA-1 Hash
hasher := sha1.New()
- _, err := hasher.Write(p15.cert.RawSubject)
+ _, err := hasher.Write(p15.Cert.RawSubject)
if err != nil {
panic(err)
}
@@ -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.
- binary.BigEndian.PutUint32(time, uint32(p15.cert.NotBefore.Unix()))
+ 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..9df0ecd 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,86 @@ 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
- cert *x509.Certificate
+ Cert *x509.Certificate
+ key crypto.PrivateKey
+ // store the encrypted enveloped Private Key for re-use
+ envelopedPrivateKey []byte
+}
+
+// KeyType is used by consumers to check for compatibility
+type KeyType int
+
+const (
+ KeyTypeRSA1024 KeyType = iota
+ KeyTypeRSA2048
+ KeyTypeRSA3072
+ KeyTypeRSA4096
+
+ KeyTypeECP256
+ KeyTypeECP384
+ KeyTypeECP521
+
+ KeyTypeUnknown
+)
+
+// String returns the private key type in a log friendly string format.
+func (keyType KeyType) String() string {
+ switch keyType {
+ case KeyTypeRSA1024:
+ return "RSA 1024-bit"
+ case KeyTypeRSA2048:
+ return "RSA 2048-bit"
+ case KeyTypeRSA3072:
+ return "RSA 3072-bit"
+ case KeyTypeRSA4096:
+ return "RSA 4096-bit"
+
+ case KeyTypeECP256:
+ return "ECDSA P-256"
+ case KeyTypeECP384:
+ return "ECDSA P-384"
+ case KeyTypeECP521:
+ return "ECDSA P-521"
+
+ default:
+ }
+
+ return "unknown key type"
+}
+
+// KeyType returns the private key type
+func (p15 *pkcs15KeyCert) KeyType() KeyType {
+ switch pKey := p15.key.(type) {
+ case *rsa.PrivateKey:
+ switch pKey.N.BitLen() {
+ case 1024:
+ return KeyTypeRSA1024
+ case 2048:
+ return KeyTypeRSA2048
+ case 3072:
+ return KeyTypeRSA3072
+ case 4096:
+ return KeyTypeRSA4096
+
+ default:
+ }
+
+ case *ecdsa.PrivateKey:
+ switch pKey.Curve.Params().Name {
+ case "P-256":
+ return KeyTypeECP256
+ case "P-384":
+ return KeyTypeECP384
+ case "P-521":
+ return KeyTypeECP521
+
+ default:
+ }
+
+ default:
+ }
+
+ return KeyTypeUnknown
}
// ParsePEMToPKCS15 parses the provide pem files to a pkcs15 struct; it also does some
@@ -27,9 +107,16 @@ func ParsePEMToPKCS15(keyPem, certPem []byte) (*pkcs15KeyCert, error) {
return nil, err
}
+ // create p15 struct
p15 := &pkcs15KeyCert{
key: key,
- cert: cert,
+ Cert: cert,
+ }
+
+ // pre-calculate encrypted envelope
+ err = p15.computeEncryptedKeyEnvelope()
+ if err != nil {
+ return nil, err
}
return p15, nil
diff --git a/pkg/pkcs15/pem_to_p15.go b/pkg/pkcs15/pem_to_p15.go
index a7a9fef..a68aba2 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,23 +109,265 @@ 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(),
}),
// CommonKeyAttributes - startDate
- asn1obj.GeneralizedTime(p15.cert.NotBefore),
+ asn1obj.GeneralizedTime(p15.Cert.NotBefore),
// CommonKeyAttributes - [4] endDate
- asn1obj.GeneralizedTimeExplicitValue(4, p15.cert.NotAfter),
+ asn1obj.GeneralizedTimeExplicitValue(4, p15.Cert.NotAfter),
}),
// actual certificate itself
asn1obj.ExplicitCompound(1, [][]byte{
asn1obj.Sequence([][]byte{
asn1obj.ExplicitCompound(0, [][]byte{
- p15.cert.Raw,
+ p15.Cert.Raw,
}),
}),
}),
})
- 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