Compare commits

...

59 commits
v0.4.1 ... main

Author SHA1 Message Date
Greg T. Wallace
86feabd939 v1.2.2 2025-04-22 18:27:55 -04:00
Greg T. Wallace
124c06d8be build: compile linux/arm64 in native runner 2025-04-22 18:27:55 -04:00
Greg T. Wallace
72f3f42baa build: add darwin arm64 & amd64 2025-04-22 18:27:54 -04:00
Greg T. Wallace
3bb6b2a3c1 dep: update all 2025-04-22 18:27:54 -04:00
Greg T. Wallace
1392529a3f dep: go 1.24.2 2025-04-22 18:27:54 -04:00
Greg T. Wallace
e87a3100d2 v1.2.1 2025-03-17 22:06:52 -04:00
Greg T. Wallace
c67001f0e4 dep: update all 2025-03-17 22:06:46 -04:00
Greg T. Wallace
ad8c4e88a9 dep: go 1.24.1 2025-03-17 22:05:29 -04:00
FingerlessGloves
096b50187a
Fix GetTime for GMT users ()
UPS failes to use the required `+` when in the GMT timezone. Account for that.

---------

Co-authored-by: Greg T. Wallace <greg@gregtwallace.com>
2025-03-17 22:01:37 -04:00
Greg T. Wallace
c5bb8edbec update screenshot 2025-01-28 21:04:06 -05:00
Greg T. Wallace
2e082a30cf v1.2.0 2025-01-27 19:54:04 -05:00
Greg T. Wallace
06b76700c4 dep: update all 2025-01-27 19:47:13 -05:00
Greg T. Wallace
7f377fc5da dep: build with ubuntu-24.04 2025-01-27 19:23:18 -05:00
Greg T. Wallace
eedbdfcc2a dep: go 1.23.5 2025-01-27 19:22:45 -05:00
Greg T. Wallace
47b964d6ee install: add time check and warning
Clock skew can cause problems with SSL and certificates. Check the UPS clock and log a warning for the user if the UPS clock is more than 1 hour different than the clock of the system this tool is running on

see: https://github.com/gregtwallace/apc-p15-tool/issues/11#issuecomment-2609010943
2025-01-27 19:11:06 -05:00
Greg T. Wallace
1cfd35c4e2 readme: escape asterisks 2024-09-17 18:50:06 -04:00
Greg T. Wallace
94a76b93de v1.1.0 2024-09-17 18:44:35 -04:00
Greg T. Wallace
1cd9916a17 install: add web ui cert verification
* connect to the ups web ui after install and verify the proper certificate is being served
* rename `apchost` flag to `hostname`
* separate ports to additional flags (`sshport` `sslport`) with sane defaults
2024-09-17 18:44:34 -04:00
Greg T. Wallace
c22447b0c2 readme: update info re: modern key support 2024-09-17 18:44:33 -04:00
Greg T. Wallace
cbb831e009 add ecdsa key support and enable 4,092 RSA
* apcssh: add descriptive error when required file(s) not passed
* create: dont create key+cert file when key isn't supported by NMC2
* config: fix usage messages re: key types
* p15 files: dont generate key+cert when it isn't needed (aka NMC2 doesn't support key)
* pkcs15: pre-calculate envelope when making the p15 struct
* pkcs15: omit key ID 8 & 9 from EC keys
* pkcs15: update key decode logic
* pkcs15: add key type value for easy determination of compatibility
* pkcs15: add ec key support
* pkcs15: separate functions for key and key+cert p15 files
* update README
see: https://github.com/gregtwallace/apc-p15-tool/issues/6
2024-09-17 18:44:33 -04:00
Greg T. Wallace
51e5847409 go: update to 1.23.1 2024-09-17 18:44:33 -04:00
Greg T. Wallace
e2b5abc624 readme: add beta notice 2024-07-09 19:16:59 -04:00
Greg T. Wallace
b8e9a23386 v1.0.0 2024-07-01 22:35:26 -04:00
Greg T. Wallace
9578fa26ce fix go build ver 1.22.4 2024-07-01 22:35:21 -04:00
Greg T. Wallace
371a2ecc30
README: update testing setup
Update firmware on my UPS,
2024-06-26 21:34:47 -04:00
Greg T. Wallace
6363282a75 v0.5.3 2024-06-24 18:24:35 -04:00
Greg T. Wallace
7c1ad8ef43 pkcs15: add some prep for maybe ec key support later 2024-06-24 18:23:05 -04:00
Greg T. Wallace
06f9892501 add rsa 3,072 bit support 2024-06-24 18:23:02 -04:00
Greg T. Wallace
b7026ff906 v0.5.2 2024-06-19 19:57:56 -04:00
Greg T. Wallace
703c26bd27 apcssh: add shell cmd timeout
It was possible for scanner.Scan() to block indefinitely if the UPS never returned the expected prompt regex pattern. This could occur with a UPS using a prompt format I'm not aware of, or if the UPS responds in a non-standard way.

This change ensures that Scan() is aborted after a fixed amount of blocking time and the shell cmd function accordingly returns an error.

Some error messages, comments, and var names are also updated for clarity.
2024-06-19 19:56:17 -04:00
Greg T. Wallace
841a459dca apcssh: minor log and logic clarity 2024-06-19 19:56:16 -04:00
Greg T. Wallace
f1dd079632 v0.5.1 2024-06-18 21:38:00 -04:00
Greg T. Wallace
04307eff17 readme: update general info about tool and compatibility 2024-06-18 21:30:43 -04:00
Greg T. Wallace
d3ad01da0c build: fix typo for windows install only file 2024-06-18 21:30:42 -04:00
Greg T. Wallace
b94e17e8f3 readme: update info regarding insecure ssh ciphers 2024-06-18 21:30:41 -04:00
Greg T. Wallace
208827f636 ssh: fix shell regex
* from ssh videos I found on youtube, the @ symbol might not be present in prompt, so make it optional
* fix typo of 0-0 instead of 0-9 (all numbers are possible in the prompt)
2024-06-18 21:30:40 -04:00
Greg T. Wallace
7bf70c4d71 ssh: switch string comps to EqualFold func 2024-06-18 21:30:39 -04:00
Greg T. Wallace
c669621bd3 install: add ssh connect log message 2024-06-18 21:30:38 -04:00
Greg T. Wallace
a47dd3fb68 go: update to 1.22.4 2024-06-06 22:52:54 -04:00
Greg T. Wallace
67503e6636 v0.5.0-preview2 2024-06-06 22:52:54 -04:00
Greg T. Wallace
579419ae31 cmd: remove cmd done log msgs
remove these unncessary log messages because it says done before any returned error (which could imply it didn't error)
2024-06-06 22:52:54 -04:00
Greg T. Wallace
12c613f3b4 apcssh: remove logging
For sanity and consistency, centralize logging in the app with the app's loggers.
2024-06-06 22:52:54 -04:00
Greg T. Wallace
ce9958e422 create: always produce both p15 files 2024-06-06 22:52:54 -04:00
Greg T. Wallace
dda11df624 install: add support for native ssl command
The code should auto-select the native ssl method if the ssl command is available on the UPS.

If this fails, install will drop back to the original install method used by this tool (which works on NMC2).
2024-06-06 22:52:54 -04:00
Greg T. Wallace
06c9263bc4 ssh: breakout ups ssh to its own package
This was done for clearer separation of function. A subsequent update will (hopefully) make the SSL command more robust so it works for both NMC2 and NMC3.

The method for sending shell commands was also updated to use an interactive shell instead. This allows capturing responses of the commands which will be needed to deduce if devices are NMC2 or NMC3.
2024-06-06 22:52:54 -04:00
Greg T. Wallace
41efc56c62 ssh: clarify log error msg 2024-06-06 22:52:54 -04:00
Greg T. Wallace
7a415f5c85 v0.5.0-preview1 2024-06-06 22:52:46 -04:00
Greg T. Wallace
7dcf0f10b9 create: fix header debug file 2024-06-04 19:00:56 -04:00
Greg T. Wallace
b44b49cd19 create: add additional flag to signal creation of additional key.p15 2024-06-04 18:59:36 -04:00
Greg T. Wallace
f0253ccaf2 create: set file permissiosns to owner only 2024-06-04 18:59:36 -04:00
Greg T. Wallace
da84a7b085 debug: add base64 encoded debug files
When troubleshooting it is helpful to put the generated files into an asn1 decoder. The files can be copy/pasted easily in b64 format.

This change creates b64 files when the debug flag is set to make this process easier.
2024-06-04 18:59:36 -04:00
Greg T. Wallace
01be6ca577 add p15 key output file
The NMC Security Wizard can also produce .p15 files that contain just a private key. Add this ability to this tool.

When the `create` function is used, both files will be outputted.
2024-06-04 18:59:36 -04:00
Greg T. Wallace
ecf10f1fdc go: update to 1.22.3 2024-06-04 18:59:35 -04:00
Greg T. Wallace
d09c7fa8fc update README for Cert Warden 2024-04-15 19:36:35 -04:00
Greg T. Wallace
ad3ee0d7f5 v0.4.2 2024-03-29 17:17:47 -04:00
Greg T. Wallace
6fe53b9fc6
Merge pull request from k725/k725-patch-1
fix usage message
2024-03-29 17:13:16 -04:00
k725
0476db7c35
fix usage message 2024-03-29 23:47:37 +09:00
Greg T. Wallace
15c6c6488e minor envelope reorg 2024-03-17 13:45:55 -04:00
Greg T. Wallace
02bc7c1239 changelog formatting 2024-03-06 17:27:16 -05:00
31 changed files with 1778 additions and 559 deletions

View file

@ -8,11 +8,11 @@ on:
env:
GITHUB_REF: ${{ github.ref }}
GO_VERSION: '1.22.1'
GO_VERSION: '1.24.2'
jobs:
build-common:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- name: Checkout Main Repo
@ -40,8 +40,10 @@ jobs:
name: CHANGELOG.md
path: ./CHANGELOG.md
###
build-linux-arm64:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout Repo
uses: actions/checkout@v4
@ -50,12 +52,6 @@ jobs:
ref: ${{ env.GITHUB_REF }}
fetch-depth: 0
- name: Update apt
run: sudo apt update
- name: Install cross-compiler for linux/arm64
run: sudo apt-get -y install gcc-aarch64-linux-gnu
- name: Set up Go
uses: actions/setup-go@v5
with:
@ -66,7 +62,6 @@ jobs:
env:
GOOS: linux
GOARCH: arm64
CC: aarch64-linux-gnu-gcc
CGO_ENABLED: 0
- name: Save Compiled Binary
@ -90,7 +85,7 @@ jobs:
path: ./apc-p15-install
build-linux-amd64:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- name: Checkout Backend Repo
uses: actions/checkout@v4
@ -171,9 +166,93 @@ jobs:
name: apc-p15-install-windows-amd64
path: ./apc-p15-install.exe
build-darwin-arm64:
runs-on: macos-15
steps:
- name: Checkout Backend Repo
uses: actions/checkout@v4
with:
repository: gregtwallace/apc-p15-tool
ref: ${{ env.GITHUB_REF }}
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '${{ env.GO_VERSION }}'
- name: Build Tool
run: go build -o ./apc-p15-tool -v ./cmd/tool
env:
GOOS: darwin
GOARCH: arm64
CGO_ENABLED: 0
- name: Save Compiled Binary
uses: actions/upload-artifact@v4
with:
name: apc-p15-tool-darwin-arm64
path: ./apc-p15-tool
- name: Build Install Only
run: go build -o ./apc-p15-install -v ./cmd/install_only
env:
GOOS: darwin
GOARCH: arm64
CGO_ENABLED: 0
- name: Save Compiled Binary
uses: actions/upload-artifact@v4
with:
name: apc-p15-install-darwin-arm64
path: ./apc-p15-install
build-darwin-amd64:
runs-on: macos-13
steps:
- name: Checkout Backend Repo
uses: actions/checkout@v4
with:
repository: gregtwallace/apc-p15-tool
ref: ${{ env.GITHUB_REF }}
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '${{ env.GO_VERSION }}'
- name: Build Tool
run: go build -o ./apc-p15-tool -v ./cmd/tool
env:
GOOS: darwin
GOARCH: amd64
CGO_ENABLED: 0
- name: Save Compiled Binary
uses: actions/upload-artifact@v4
with:
name: apc-p15-tool-darwin-amd64
path: ./apc-p15-tool
- name: Build Install Only
run: go build -o ./apc-p15-install -v ./cmd/install_only
env:
GOOS: darwin
GOARCH: amd64
CGO_ENABLED: 0
- name: Save Compiled Binary
uses: actions/upload-artifact@v4
with:
name: apc-p15-install-darwin-amd64
path: ./apc-p15-install
###
release-file-linux-arm64:
needs: [build-common, build-linux-arm64]
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- name: Make release directory
@ -217,7 +296,7 @@ jobs:
release-file-linux-amd64:
needs: [build-common, build-linux-amd64]
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- name: Make release directory
@ -261,7 +340,7 @@ jobs:
release-file-windows-amd64:
needs: [build-common, build-windows-amd64]
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- name: Make release directory
@ -302,3 +381,91 @@ jobs:
with:
name: apc-p15-tool_windows_amd64
path: ./release
release-file-darwin-arm64:
needs: [build-common, build-darwin-arm64]
runs-on: ubuntu-24.04
steps:
- name: Make release directory
run: mkdir ./release
- name: Download Tool Binary
uses: actions/download-artifact@v4
with:
name: apc-p15-tool-darwin-arm64
path: ./release
- name: Download Install Binary
uses: actions/download-artifact@v4
with:
name: apc-p15-install-darwin-arm64
path: ./release
- name: Download README
uses: actions/download-artifact@v4
with:
name: README.md
path: ./release
- name: Download LICENSE
uses: actions/download-artifact@v4
with:
name: LICENSE.md
path: ./release
- name: Download CHANGELOG
uses: actions/download-artifact@v4
with:
name: CHANGELOG.md
path: ./release
- name: Save Release
uses: actions/upload-artifact@v4
with:
name: apc-p15-tool_darwin_arm64
path: ./release
release-file-darwin-amd64:
needs: [build-common, build-darwin-amd64]
runs-on: ubuntu-24.04
steps:
- name: Make release directory
run: mkdir ./release
- name: Download Tool Binary
uses: actions/download-artifact@v4
with:
name: apc-p15-tool-darwin-amd64
path: ./release
- name: Download Install Binary
uses: actions/download-artifact@v4
with:
name: apc-p15-install-darwin-amd64
path: ./release
- name: Download README
uses: actions/download-artifact@v4
with:
name: README.md
path: ./release
- name: Download LICENSE
uses: actions/download-artifact@v4
with:
name: LICENSE.md
path: ./release
- name: Download CHANGELOG
uses: actions/download-artifact@v4
with:
name: CHANGELOG.md
path: ./release
- name: Save Release
uses: actions/upload-artifact@v4
with:
name: apc-p15-tool_darwin_amd64
path: ./release

1
.gitignore vendored
View file

@ -1,6 +1,7 @@
# key/cert files
*.p15
*.pem
*.b64
# ignore test_data folder
/_test_data

View file

@ -1,42 +1,146 @@
# APC P15 Tool Changelog
## [v0.4.1] - 2024.03.06
## [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
## [v0.4.0] - 2024-02-05
Add `--restartwebui` flag to issue a reboot command to the webui
after a new certificate is installed. This was not needed with
after a new certificate is installed. This was not needed with
my NMC2, but I suspect some might need it to get the new certificate
to actually load.
## [v0.3.3] - 2024.02.04
## [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.

132
README.md
View file

@ -1,30 +1,106 @@
# APC P15 Tool
A tool to create APC p15 formatted certificates from pem files, without
having to use APC's closed-source tool, APC generated keys, or other
proprietary tools (such as cryptlib).
APC P15 Tool is a completely open source application designed to make
creating and installing SSL certificates on APC (Schneider Electric)
Network Management Cards (2 & 3) simple and easy to do. It is also
designed to simplify automation of the certificate management lifecycle.
## Background
When APC created the NMC2 (Network Management Card 2), they chose to use
the p15 file format for their SSL keys and certificates, which is a
relatively obscure file format. In addition to this, they designed the
device to require an APC specific header be prepended to the p15 file
or the file would be rejected by the device. Accordingly, they created
a proprietary tool (the `NMC Security Wizard CLI Utility`) to generate
the required format.
Unfortunately, the proprietary tool has a number of shortcomings:
- It can be difficult to find the right version to use. APC has released
a number of versions (in both a CLI and GUI form). Not all of the
versions worked correctly (or at all).
- User provided private keys are not supported. Private keys must be
generated by the proprietary tool and are only outputted in the p15
format. APC's proprietary tool is closed source and as such there is
no way to audit the key generation process.
- Since the generated keys are in the p15 format, they can't be loaded
easily into other management tools (such as Cert Warden
https://www.certwarden.com/), nor can CSRs be generated easily
outside of the proprietary tool. The proprietary tool is generally
required to generate the CSR.
- The CSR generation function in the proprietary tool is fairly rigid,
making customization (e.g., multiple DNS names) difficult, if not
impossible.
- After the user generates a key, generates a CSR, sends that CSR to
their CA, and receives a certificate back, they're still not done.
The tool must be used again to generate the final p15 file for the
NMC.
- To install the final file on the NMC, the user must use an SCP
program such as `pscp` to install the file, or the NMC's web UI.
Due to all of this, others have tried to recreate the proprietary
functionality. The only implementations I have found rely on a closed
source library called `cryptlib`. This library has evolved over time
and more recent versions do not work for the NMC (it appears at some
point cryptlib switched from 3DES to AES and NMC does not support
AES within the p15 file). It was also near impossible to find an old
enough version of cryptlib that would work. Even if one gets this
working, it does not resolve the obscurity of a closed source
implementation and would continue to be subject to potential future
breakage as the cryptlib library continues to evolve.
This project aims to solve all of these problems by accepting the most
common key and cert file format (PEM) and by being 100% open source
and licensed under the GPL-3.0 license.
## Compatibility Notice
This tool's create functionality is modeled from the APC NMCSecurityWizardCLI
aka `NMC Security Wizard CLI Utility`. The files it generates should be
comaptible with any UPS that accepts p15 files from that tool. Only RSA 1,024
and 2,048 bit keys are accepted. 1,024 bit RSA is no longer considered
completely secure; avoid keys of this size if possible. Most (all?) public
ACME services won't accept keys of this size anyway.
Both NMC2 and NMC3 devices should be fully supported. However, I have one
NMC2 device in a home lab and have no way to guarantee success in all cases.
The install functionality is a custom creation of mine so it may or may not
work depending on your exact setup. My setup (and therefore the testing
setup) is:
### Key Types and Sizes
NMC2:
- RSA 1,024, 2,048, 3,072* bit lengths.
NMC3*:
- RSA 1,024, 2,048, 3,072, and 4,092 bit lengths.
- ECDSA curves P-256, P-384, and P-521.
\* 3,072 bit length is not officially supported by my NMC2, but appears to work
fine.
\* The additional key types supported by NMC3 require newer firmware on the
device. I am unsure what the version cutoff is, but you can check support
by connecting to the UPS via SSH and typing `ssl`. If `Command Not Found`
is returned, the firmware is too old and only the key types listed under
NMC2 will work.
1,024 bit RSA is no longer considered completely secure; avoid keys of
this size if possible. Most (all?) public ACME services won't accept keys
of this size anyway.
### General Troubleshooting
My setup (and therefore the testing setup) is:
- APC Smart-UPS 1500VA RM 2U SUA1500RM2U (Firmware Revision 667.18.D)
- AP9631 NMC2 Hardware Revision 05 running AOS v7.0.4 and Boot Monitor
- AP9631 NMC2 Hardware Revision 05 running AOS v7.1.2 and Boot Monitor
v1.0.9.
If you have problems you can post the log in an issue and I can try to fix it
but it may be difficult without your particular hardware to test with.
If you have trouble, your first step should be to update your NMC's firmware.
Many issues with this tool will be resolved simply by updating to the newest
firmware.
In particular, if you are experiencing `ssh: handshake failed:` please run
`ssh -vv myups.example.com` and include the `peer server KEXINIT proposal`
in your issue. For example:
If you have a problem after that, please post the log in an issue and I can
try to fix it but it may be difficult without your particular hardware to
test with.
In particular, if you are experiencing `ssh: handshake failed:` first try
using the `--insecurecipher` flag. If this works, you should upgrade your
NMC to a newer firmware which includes secure ciphers. You should NOT automate
your environment using this flag as SSH over these ciphers is broken and
exploitable. If this also does not work, please run `ssh -vv myups.example.com`
and include the `peer server KEXINIT proposal` in your issue. For example:
```
debug2: peer server KEXINIT proposal
@ -59,16 +135,18 @@ content.
e.g. `./apc-p15-tool create --keyfile ./apckey.pem --certfile ./apccert.pem`
The command outputs ./apctool.p15 by default. This file can be
directly loaded on to an APC NMC2 (Network Management Card 2).
The command creates and outputs ./apctool.p15 and ./apctool.key.p15 by
default. These files are equivelant to the key and final p15 files
generated by APC's proprietary tool.
### Install
Install works similarly to create except it doesn't save the p15 file
to disk. It instead uploads the p15 file directly to the specified
remote host, via scp.
Install generates the necessary p15 file(s) but does NOT save them to
disk. It instead installs the files directly on the NMC. Logic
automatically deduces if the device is an NMC2 or NMC3 and performs
the appropriate installation steps.
e.g. `./apc-p15-tool install --keyfile ./apckey.pem --certfile ./apccert.pem --apchost myapc.example.com:22 --username apc --password someSecret --fingerprint 123abc`
e.g. `./apc-p15-tool install --keyfile ./apckey.pem --certfile ./apccert.pem --hostname myapc.example.com --username apc --password someSecret --fingerprint 123abc`
## Note About Install Automation
@ -86,12 +164,12 @@ for passing the pem content from another application without having
to save the pem files to disk.
Putting all of this together, you can combine the install binary with
a tool like LeGo CertHub (https://www.legocerthub.com/) to call the
a tool like Cert Warden (https://www.certwarden.com/) to call the
install binary, with environment variables, to directly upload new
certificates as they're issued by LeGo, without having to write a
certificates as they're issued by Cert Warden, without having to write a
separate script.
![LeGo CertHub with APC P15 Tool](https://raw.githubusercontent.com/gregtwallace/apc-p15-tool/main/img/apc-p15-tool.png)
![Cert Warden with APC P15 Tool](https://raw.githubusercontent.com/gregtwallace/apc-p15-tool/main/img/apc-p15-tool.png)
## Thanks

View file

@ -11,7 +11,7 @@ go build -o $outDir/apc-p15-tool-amd64.exe ./cmd/tool
$env:GOARCH = "amd64"
$env:GOOS = "windows"
$env:CGO_ENABLED = 0
go build -o $outDir/apc-p15-install-amd64.exe ./cmd/tool
go build -o $outDir/apc-p15-install-amd64.exe ./cmd/install_only
# Linux x64
$env:GOARCH = "amd64"
@ -34,3 +34,25 @@ $env:GOARCH = "arm64"
$env:GOOS = "linux"
$env:CGO_ENABLED = 0
go build -o $outDir/apc-p15-install-arm64 ./cmd/install_only
# Darwin (MacOS) amd64
$env:GOARCH = "amd64"
$env:GOOS = "darwin"
$env:CGO_ENABLED = 0
go build -o $outDir/apc-p15-tool-darwin-amd64 ./cmd/tool
$env:GOARCH = "amd64"
$env:GOOS = "darwin"
$env:CGO_ENABLED = 0
go build -o $outDir/apc-p15-install-darwin-amd64 ./cmd/install_only
# Darwin (MacOS) arm64
$env:GOARCH = "arm64"
$env:GOOS = "darwin"
$env:CGO_ENABLED = 0
go build -o $outDir/apc-p15-tool-darwin-arm64 ./cmd/tool
$env:GOARCH = "arm64"
$env:GOOS = "darwin"
$env:CGO_ENABLED = 0
go build -o $outDir/apc-p15-install-darwin-arm64 ./cmd/install_only

10
go.mod
View file

@ -1,19 +1,21 @@
module apc-p15-tool
go 1.22.1
go 1.24.2
require (
github.com/peterbourgon/ff/v4 v4.0.0-alpha.4
github.com/sigurn/crc16 v0.0.0-20211026045750-20ab5afb07e3
golang.org/x/crypto v0.18.0
github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1
golang.org/x/crypto v0.37.0
)
require golang.org/x/sys v0.16.0 // indirect
require golang.org/x/sys v0.32.0 // indirect
replace apc-p15-tool/cmd/install_only => /cmd/install_only
replace apc-p15-tool/cmd/tool => /cmd/tool
replace apc-p15-tool/pkg/apcssh => /pkg/apcssh
replace apc-p15-tool/pkg/app => /pkg/app
replace apc-p15-tool/pkg/pkcs15 => /pkg/pkcs15

16
go.sum
View file

@ -2,13 +2,13 @@ github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N
github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/peterbourgon/ff/v4 v4.0.0-alpha.4 h1:aiqS8aBlF9PsAKeMddMSfbwp3smONCn3UO8QfUg0Z7Y=
github.com/peterbourgon/ff/v4 v4.0.0-alpha.4/go.mod h1:H/13DK46DKXy7EaIxPhk2Y0EC8aubKm35nBjBe8AAGc=
github.com/sigurn/crc16 v0.0.0-20211026045750-20ab5afb07e3 h1:aQKxg3+2p+IFXXg97McgDGT5zcMrQoi0EICZs8Pgchs=
github.com/sigurn/crc16 v0.0.0-20211026045750-20ab5afb07e3/go.mod h1:9/etS5gpQq9BJsJMWg1wpLbfuSnkm8dPF6FdW2JXVhA=
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1 h1:NVK+OqnavpyFmUiKfUMHrpvbCi2VFoWTrcpI7aDaJ2I=
github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1/go.mod h1:9/etS5gpQq9BJsJMWg1wpLbfuSnkm8dPF6FdW2JXVhA=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=

Binary file not shown.

Before

(image error) Size: 102 KiB

After

(image error) Size: 93 KiB

Before After
Before After

128
pkg/apcssh/client.go Normal file
View file

@ -0,0 +1,128 @@
package apcssh
import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"net"
"runtime"
"strings"
"time"
"golang.org/x/crypto/ssh"
)
const (
apcSSHVer = 1
sshTimeout = 90 * time.Second
)
// APC UPS won't except Go's SSH "Run()" command as the format isn't quite
// the same. Therefore, write a custom implementation instead of relying on
// something like github.com/bramvdbogaerde/go-scp
type Config struct {
Hostname string
Username string
Password string
ServerFingerprint string
InsecureCipher bool
}
// Client is an APC UPS SSH client
type Client struct {
hostname string
sshCfg *ssh.ClientConfig
}
// New creates a new SSH Client for the APC UPS.
func New(cfg *Config) (*Client, error) {
// make host key callback
hk := func(_hostname string, _remote net.Addr, key ssh.PublicKey) error {
// calculate server's key's SHA256
hasher := sha256.New()
_, err := hasher.Write(key.Marshal())
if err != nil {
return err
}
actualHash := hasher.Sum(nil)
// log fingerprint for debugging
actualHashB64 := base64.RawStdEncoding.EncodeToString(actualHash)
actualHashHex := hex.EncodeToString(actualHash)
// check for fingerprint match (b64 or hex)
if actualHashB64 != cfg.ServerFingerprint && actualHashHex != cfg.ServerFingerprint {
return fmt.Errorf("apcssh: server returned wrong fingerprint (b64: %s ; hex: %s)", actualHashB64, actualHashHex)
}
return nil
}
// kex algos
// see defaults: https://cs.opensource.google/go/x/crypto/+/refs/tags/v0.18.0:ssh/common.go;l=62
kexAlgos := []string{
"curve25519-sha256", "curve25519-sha256@libssh.org",
"ecdh-sha2-nistp256", "ecdh-sha2-nistp384", "ecdh-sha2-nistp521",
"diffie-hellman-group14-sha256", "diffie-hellman-group14-sha1",
}
// extra for some apc ups
kexAlgos = append(kexAlgos, "diffie-hellman-group-exchange-sha256")
// ciphers
// see defaults: https://cs.opensource.google/go/x/crypto/+/master:ssh/common.go;l=37
ciphers := []string{
"aes128-gcm@openssh.com", "aes256-gcm@openssh.com",
"chacha20-poly1305@openssh.com",
"aes128-ctr", "aes192-ctr", "aes256-ctr",
}
// insecure cipher options?
if cfg.InsecureCipher {
ciphers = append(ciphers, "aes128-cbc", "3des-cbc")
}
// install file on UPS
// ssh config
config := &ssh.ClientConfig{
User: cfg.Username,
Auth: []ssh.AuthMethod{
ssh.Password(cfg.Password),
},
// APC seems to require `Client Version` string to start with "SSH-2" and must be at least
// 13 characters long
// working examples from other clients:
// ClientVersion: "SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.6",
// ClientVersion: "SSH-2.0-PuTTY_Release_0.80",
ClientVersion: fmt.Sprintf("SSH-2.0-apcssh_v%d %s-%s", apcSSHVer, runtime.GOOS, runtime.GOARCH),
Config: ssh.Config{
KeyExchanges: kexAlgos,
Ciphers: ciphers,
},
HostKeyCallback: hk,
// reasonable timeout for file copy
Timeout: sshTimeout,
}
// if hostname missing a port, add default
if !strings.Contains(cfg.Hostname, ":") {
cfg.Hostname = cfg.Hostname + ":22"
}
// connect to ups over SSH (to verify everything works)
sshClient, err := ssh.Dial("tcp", cfg.Hostname, config)
if err != nil {
return nil, err
}
_ = sshClient.Close()
// return Client (note: new ssh Dial will be done for each action as the UPS
// seems to not do well with more than one Session per Dial)
return &Client{
hostname: cfg.Hostname,
sshCfg: config,
}, nil
}

62
pkg/apcssh/cmd_gettime.go Normal file
View file

@ -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
}

View file

@ -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
}

129
pkg/apcssh/scp.go Normal file
View file

@ -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
}

139
pkg/apcssh/shell.go Normal file
View file

@ -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
}

View file

@ -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
}

86
pkg/apcssh/ssl.go Normal file
View file

@ -0,0 +1,86 @@
package apcssh
import (
"errors"
"fmt"
"strings"
)
var errSSLMissingData = errors.New("apcssh: ssl cert install: cant install nil data (unsupported key/nmc version/nmc firmware combo?)")
// InstallSSLCert installs the specified p15 key and p15 cert files on the
// UPS. It has logic to deduce if the NMC is a newer version (e.g., NMC3 with
// newer firmware) and acts accordingly.
func (cli *Client) InstallSSLCert(keyP15 []byte, certPem []byte, keyCertP15 []byte) error {
// run `ssl` command to check if it exists
result, err := cli.cmd("ssl")
if err != nil {
return fmt.Errorf("apcssh: ssl cert install: failed to test ssl cmd (%w)", err)
}
// E101 is the code for "Command Not Found"
supportsSSLCmd := !strings.EqualFold(result.code, "e101")
// if SSL is supported, use that method
if supportsSSLCmd {
return cli.installSSLCertModern(keyP15, certPem)
}
// fallback to legacy
return cli.installSSLCertLegacy(keyCertP15)
}
// installSSLCertModern installs the SSL key and certificate using the UPS built-in
// command `ssl`. This command is not present on older devices (e.g., NMC2) or firmwares.
func (cli *Client) installSSLCertModern(keyP15 []byte, certPem []byte) error {
// fail if required data isn't present
if keyP15 == nil || len(keyP15) <= 0 || certPem == nil || len(certPem) <= 0 {
return errSSLMissingData
}
// upload the key P15 file
err := cli.UploadSCP("/ssl/nmc.key", keyP15, 0600)
if err != nil {
return fmt.Errorf("apcssh: ssl cert install: failed to send nmc.key file to ups over scp (%w)", err)
}
// upload the cert PEM file
err = cli.UploadSCP("/ssl/nmc.crt", certPem, 0666)
if err != nil {
return fmt.Errorf("apcssh: ssl cert install: failed to send nmc.key file to ups over scp (%w)", err)
}
// run `ssl` install commands
result, err := cli.cmd("ssl key -i /ssl/nmc.key")
if err != nil {
return fmt.Errorf("apcssh: ssl cert install: failed to send ssl key install cmd (%w)", err)
} else if !strings.EqualFold(result.code, "e000") {
return fmt.Errorf("apcssh: ssl cert install: ssl key install cmd returned error code (%s: %s)", result.code, result.codeText)
}
result, err = cli.cmd("ssl cert -i /ssl/nmc.crt")
if err != nil {
return fmt.Errorf("apcssh: ssl cert install: failed to send ssl cert install cmd (%w)", err)
} else if !strings.EqualFold(result.code, "e000") {
return fmt.Errorf("apcssh: ssl cert install: ssl cert install cmd returned error code (%s: %s)", result.code, result.codeText)
}
return nil
}
// installSSLCertLegacy installs the SSL key and certificate by directly uploading
// them to a .p15 file on the UPS. This is used for older devices (e.g., NMC2) and
// firmwares that do not support the `ssl` command.
func (cli *Client) installSSLCertLegacy(keyCertP15 []byte) error {
// fail if required data isn't present
if keyCertP15 == nil || len(keyCertP15) <= 0 {
return errSSLMissingData
}
// upload/install keyCert P15 file
err := cli.UploadSCP("/ssl/defaultcert.p15", keyCertP15, 0600)
if err != nil {
return fmt.Errorf("apcssh: ssl cert install: failed to send defaultcert.p15 file to ups over scp (%w)", err)
}
return nil
}

View file

@ -12,7 +12,7 @@ import (
)
const (
appVersion = "0.4.1"
appVersion = "1.2.2"
)
// struct for receivers to use common app pieces

View file

@ -2,18 +2,19 @@ package app
import (
"context"
"encoding/base64"
"fmt"
"os"
)
const createDefaultOutFilePath = "apctool.p15"
const (
createDefaultOutFilePath = "apctool.p15"
createDefaultOutKeyFilePath = "apctool.key.p15"
)
// cmdCreate is the app's command to create an apc p15 file from key and cert
// pem files
func (app *app) cmdCreate(_ context.Context, args []string) error {
// done
defer app.stdLogger.Println("create: done")
// extra args == error
if len(args) != 0 {
return fmt.Errorf("create: failed, %w (%d)", ErrExtraArgs, len(args))
@ -26,25 +27,67 @@ func (app *app) cmdCreate(_ context.Context, args []string) error {
// validation done
// make p15 file
apcFile, err := app.pemToAPCP15(keyPem, certPem, "create")
// make p15 files
keyFile, apcKeyCertFile, err := app.pemToAPCP15(keyPem, certPem, "create")
if err != nil {
return err
}
// determine file name (should already be done by flag parsing, but avoid nil just in case)
fileName := createDefaultOutFilePath
keyCertFileName := createDefaultOutFilePath
if app.config.create.outFilePath != nil && *app.config.create.outFilePath != "" {
fileName = *app.config.create.outFilePath
keyCertFileName = *app.config.create.outFilePath
}
// write file
err = os.WriteFile(fileName, apcFile, 0777)
keyFileName := createDefaultOutFilePath
if app.config.create.outKeyFilePath != nil && *app.config.create.outKeyFilePath != "" {
keyFileName = *app.config.create.outKeyFilePath
}
// write file(s)
err = os.WriteFile(keyFileName, keyFile, 0600)
if err != nil {
return fmt.Errorf("create: failed to write apc p15 file (%s)", err)
return fmt.Errorf("create: failed to write apc p15 key file (%s)", err)
}
app.stdLogger.Printf("create: apc p15 key file %s written to disk", keyFileName)
// skip key+cert if it wasn't generated
if len(apcKeyCertFile) > 0 {
err = os.WriteFile(keyCertFileName, apcKeyCertFile, 0600)
if err != nil {
return fmt.Errorf("create: failed to write apc p15 key+cert file (%s)", err)
}
app.stdLogger.Printf("create: apc p15 key+cert file %s written to disk", keyCertFileName)
}
app.stdLogger.Printf("create: apc p15 file %s written to disk", fileName)
// if debug, write additional debug files (b64 format to make copy/paste into asn1 decoder
// easy to do e.g., https://lapo.it/asn1js)
if app.config.debugLogging != nil && *app.config.debugLogging {
keyFileNameDebug := keyFileName + ".b64"
err = os.WriteFile(keyFileNameDebug, []byte(base64.StdEncoding.EncodeToString(keyFile)), 0600)
if err != nil {
return fmt.Errorf("create: failed to write apc p15 key file (%s)", err)
}
app.debugLogger.Printf("create: apc p15 key file %s written to disk", keyFileNameDebug)
// skip key+cert if it wasn't generated
if len(apcKeyCertFile) > 0 {
keyCertFileNameDebug := keyCertFileName + ".noheader.b64"
err = os.WriteFile(keyCertFileNameDebug, []byte(base64.StdEncoding.EncodeToString(apcKeyCertFile[apcHeaderLen:])), 0600)
if err != nil {
return fmt.Errorf("create: failed to write apc p15 key+cert file (%s)", err)
}
app.debugLogger.Printf("create: apc p15 key+cert file %s written to disk", keyCertFileNameDebug)
keyCertFileNameHeaderDebug := keyCertFileName + ".header.b64"
err = os.WriteFile(keyCertFileNameHeaderDebug, []byte(base64.StdEncoding.EncodeToString(apcKeyCertFile[:apcHeaderLen])), 0600)
if err != nil {
return fmt.Errorf("create: failed to write apc p15 key+cert file (%s)", err)
}
app.debugLogger.Printf("create: apc p15 key+cert file header %s written to disk", keyCertFileNameHeaderDebug)
}
}
return nil
}

View file

@ -1,24 +1,22 @@
package app
import (
"apc-p15-tool/pkg/apcssh"
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"crypto/tls"
"encoding/pem"
"errors"
"fmt"
"net"
"runtime"
"golang.org/x/crypto/ssh"
"strconv"
"time"
)
const timeLoggingFormat = time.RFC1123Z
// cmdInstall is the app's command to create apc p15 file content from key and cert
// pem files and upload the p15 to the specified APC UPS
func (app *app) cmdInstall(cmdCtx context.Context, args []string) error {
// done
defer app.stdLogger.Println("install: done")
// extra args == error
if len(args) != 0 {
return fmt.Errorf("install: failed, %w (%d)", ErrExtraArgs, len(args))
@ -45,126 +43,64 @@ func (app *app) cmdInstall(cmdCtx context.Context, args []string) error {
}
// host to install on must be specified
if app.config.install.hostAndPort == nil || *app.config.install.hostAndPort == "" {
if app.config.install.hostname == nil || *app.config.install.hostname == "" ||
app.config.install.sshport == nil || *app.config.install.sshport == 0 {
return errors.New("install: failed, apc host not specified")
}
// validation done
// make p15 file
apcFile, err := app.pemToAPCP15(keyPem, certPem, "install")
keyP15, keyCertP15, err := app.pemToAPCP15(keyPem, certPem, "install")
if err != nil {
return err
}
// make host key callback
hk := func(hostname string, remote net.Addr, key ssh.PublicKey) error {
// calculate server's key's SHA256
hasher := sha256.New()
_, err := hasher.Write(key.Marshal())
if err != nil {
return err
}
actualHash := hasher.Sum(nil)
// log fingerprint for debugging
actualHashB64 := base64.RawStdEncoding.EncodeToString(actualHash)
actualHashHex := hex.EncodeToString(actualHash)
app.debugLogger.Printf("ssh: remote server key fingerprint (b64): %s", actualHashB64)
app.debugLogger.Printf("ssh: remote server key fingerprint (hex): %s", actualHashHex)
// allow base64 format
if actualHashB64 == *app.config.install.fingerprint {
return nil
}
// allow hex format
if actualHashHex == *app.config.install.fingerprint {
return nil
}
return errors.New("ssh: fingerprint didn't match")
}
// kex algos
// see defaults: https://cs.opensource.google/go/x/crypto/+/refs/tags/v0.18.0:ssh/common.go;l=62
kexAlgos := []string{
"curve25519-sha256", "curve25519-sha256@libssh.org",
"ecdh-sha2-nistp256", "ecdh-sha2-nistp384", "ecdh-sha2-nistp521",
"diffie-hellman-group14-sha256", "diffie-hellman-group14-sha1",
}
// extra for some apc ups
kexAlgos = append(kexAlgos, "diffie-hellman-group-exchange-sha256")
// ciphers
// see defaults: https://cs.opensource.google/go/x/crypto/+/master:ssh/common.go;l=37
ciphers := []string{
"aes128-gcm@openssh.com", "aes256-gcm@openssh.com",
"chacha20-poly1305@openssh.com",
"aes128-ctr", "aes192-ctr", "aes256-ctr",
}
// insecure cipher options?
// log warning if insecure cipher
if app.config.install.insecureCipher != nil && *app.config.install.insecureCipher {
app.stdLogger.Println("WARNING: insecure ciphers are enabled (--insecurecipher). SSH with an insecure cipher is NOT secure and should NOT be used.")
ciphers = append(ciphers, "aes128-cbc", "3des-cbc")
app.stdLogger.Println("WARNING: install: insecure ciphers are enabled (--insecurecipher). SSH with an insecure cipher is NOT secure and should NOT be used.")
}
// install file on UPS
// ssh config
config := &ssh.ClientConfig{
User: *app.config.install.username,
Auth: []ssh.AuthMethod{
ssh.Password(*app.config.install.password),
},
// APC seems to require `Client Version` string to start with "SSH-2" and must be at least
// 13 characters long
// working examples from other clients:
// ClientVersion: "SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.6",
// ClientVersion: "SSH-2.0-PuTTY_Release_0.80",
ClientVersion: fmt.Sprintf("SSH-2.0-apc-p15-tool_v%s %s-%s", appVersion, runtime.GOOS, runtime.GOARCH),
Config: ssh.Config{
KeyExchanges: kexAlgos,
Ciphers: ciphers,
// MACs: []string{"hmac-sha2-256"},
},
// HostKeyAlgorithms: []string{"ssh-rsa"},
HostKeyCallback: hk,
// reasonable timeout for file copy
Timeout: sshScpTimeout,
// make APC SSH client
cfg := &apcssh.Config{
Hostname: *app.config.install.hostname + ":" + strconv.Itoa(*app.config.install.sshport),
Username: *app.config.install.username,
Password: *app.config.install.password,
ServerFingerprint: *app.config.install.fingerprint,
InsecureCipher: *app.config.install.insecureCipher,
}
// connect to ups over SSH
client, err := ssh.Dial("tcp", *app.config.install.hostAndPort, config)
client, err := apcssh.New(cfg)
if err != nil {
return fmt.Errorf("install: failed to connect to host (%w)", err)
}
defer client.Close()
app.stdLogger.Println("install: connected to ups ssh, installing ssl key and cert...")
// send file to UPS
err = sshScpSendFileToUPS(client, apcFile)
// check time - don't fail it time is no good, just do logging here
upsT, err := client.GetTime()
if err != nil {
return fmt.Errorf("install: failed to send p15 file to ups over scp (%w)", err)
app.errLogger.Printf("warn: install: failed to fetch UPS time (%s), you should manually verify the time is correct on the UPS", err)
} else if upsT.After(time.Now().Add(1*time.Hour)) || upsT.Before(time.Now().Add(-1*time.Hour)) {
app.errLogger.Printf("warn: install: UPS clock skew detected (this system's time is %s vs. UPS time %s", time.Now().Format(timeLoggingFormat), upsT.Format(timeLoggingFormat))
} else {
app.stdLogger.Printf("install: UPS clock appears correct (%s)", upsT.Format(timeLoggingFormat))
}
// install SSL Cert
err = client.InstallSSLCert(keyP15, certPem, keyCertP15)
if err != nil {
return fmt.Errorf("install: %w", err)
}
// installed
app.stdLogger.Printf("install: apc p15 file installed on %s", *app.config.install.hostAndPort)
app.stdLogger.Printf("install: apc p15 file installed on %s", *app.config.install.hostname)
// restart UPS webUI
if app.config.install.restartWebUI != nil && *app.config.install.restartWebUI {
app.stdLogger.Println("install: sending restart command")
// connect to ups over SSH
// opening a second session doesn't seem to work with my NMC2 for some reason, so make
// a new connection instead
client, err = ssh.Dial("tcp", *app.config.install.hostAndPort, config)
if err != nil {
return fmt.Errorf("install: failed to reconnect to host to send webui restart command (%w)", err)
}
defer client.Close()
err = sshResetUPSWebUI(client)
err = client.RestartWebUI()
if err != nil {
return fmt.Errorf("install: failed to send webui restart command (%w)", err)
}
@ -172,5 +108,48 @@ func (app *app) cmdInstall(cmdCtx context.Context, args []string) error {
app.stdLogger.Println("install: sent webui restart command")
}
// check the new certificate is installed
if app.config.install.skipVerify != nil && !*app.config.install.skipVerify &&
app.config.install.webUISSLPort != nil && *app.config.install.webUISSLPort != 0 {
app.stdLogger.Println("install: attempting to verify certificate install...")
// sleep for UPS to finish anything it might be doing
time.Sleep(5 * time.Second)
// if UPS web UI was restarted, sleep longer
if app.config.install.restartWebUI != nil && *app.config.install.restartWebUI {
app.stdLogger.Println("install: waiting for ups webui restart...")
time.Sleep(25 * time.Second)
}
// connect to the web UI to get the current certificate
conf := &tls.Config{
InsecureSkipVerify: true,
}
conn, err := tls.Dial("tcp", *app.config.install.hostname+":"+strconv.Itoa(*app.config.install.webUISSLPort), conf)
if err != nil {
return fmt.Errorf("install: failed to dial webui for verification (%s)", err)
}
defer conn.Close()
// get top cert
leafCert := conn.ConnectionState().PeerCertificates[0]
if leafCert == nil {
return fmt.Errorf("install: failed to get web ui leaf cert for verification (%s)", err)
}
// convert pem to DER for comparison
pemBlock, _ := pem.Decode(certPem)
// verify cert is the correct one
certVerified := bytes.Equal(leafCert.Raw, pemBlock.Bytes)
if !certVerified {
return errors.New("install: web ui leaf cert does not match new cert")
}
app.stdLogger.Println("install: ups web ui cert verified")
}
return nil
}

View file

@ -28,15 +28,19 @@ type config struct {
debugLogging *bool
create struct {
keyCertPemCfg
outFilePath *string
outFilePath *string
outKeyFilePath *string
}
install struct {
keyCertPemCfg
hostAndPort *string
hostname *string
sshport *int
fingerprint *string
username *string
password *string
restartWebUI *bool
webUISSLPort *int
skipVerify *bool
insecureCipher *bool
}
}
@ -56,7 +60,7 @@ func (app *app) getConfig(args []string) error {
// apc-p15-tool -- root command
rootFlags := ff.NewFlagSet("apc-p15-tool")
cfg.debugLogging = rootFlags.BoolLong("debug", "set this flag to enable additional debug logging messages")
cfg.debugLogging = rootFlags.BoolLong("debug", "set this flag to enable additional debug logging messages and files")
rootCmd := &ff.Command{
Name: "apc-p15-tool",
@ -67,11 +71,12 @@ func (app *app) getConfig(args []string) error {
// create -- subcommand
createFlags := ff.NewFlagSet("create").SetParent(rootFlags)
cfg.create.keyPemFilePath = createFlags.StringLong("keyfile", "", "path and filename of the rsa-1024 or rsa-2048 key in pem format")
cfg.create.keyPemFilePath = createFlags.StringLong("keyfile", "", "path and filename of the key in pem format")
cfg.create.certPemFilePath = createFlags.StringLong("certfile", "", "path and filename of the certificate in pem format")
cfg.create.keyPem = createFlags.StringLong("keypem", "", "string of the rsa-1024 or rsa-2048 key in pem format")
cfg.create.keyPem = createFlags.StringLong("keypem", "", "string of the key in pem format")
cfg.create.certPem = createFlags.StringLong("certpem", "", "string of the certificate in pem format")
cfg.create.outFilePath = createFlags.StringLong("outfile", createDefaultOutFilePath, "path and filename to write the p15 file to")
cfg.create.outFilePath = createFlags.StringLong("outfile", createDefaultOutFilePath, "path and filename to write the key+cert p15 file to")
cfg.create.outKeyFilePath = createFlags.StringLong("outkeyfile", createDefaultOutKeyFilePath, "path and filename to write the key p15 file to")
createCmd := &ff.Command{
Name: "create",
@ -86,20 +91,23 @@ func (app *app) getConfig(args []string) error {
// install -- subcommand
installFlags := ff.NewFlagSet("install").SetParent(rootFlags)
cfg.install.keyPemFilePath = installFlags.StringLong("keyfile", "", "path and filename of the rsa-1024 or rsa-2048 key in pem format")
cfg.install.keyPemFilePath = installFlags.StringLong("keyfile", "", "path and filename of the key in pem format")
cfg.install.certPemFilePath = installFlags.StringLong("certfile", "", "path and filename of the certificate in pem format")
cfg.install.keyPem = installFlags.StringLong("keypem", "", "string of the rsa-1024 or rsa-2048 key in pem format")
cfg.install.keyPem = installFlags.StringLong("keypem", "", "string of the key in pem format")
cfg.install.certPem = installFlags.StringLong("certpem", "", "string of the certificate in pem format")
cfg.install.hostAndPort = installFlags.StringLong("apchost", "", "hostname:port of the apc ups to install the certificate on")
cfg.install.hostname = installFlags.StringLong("hostname", "", "hostname of the apc ups to install the certificate on")
cfg.install.sshport = installFlags.IntLong("sshport", 22, "apc ups ssh port number")
cfg.install.fingerprint = installFlags.StringLong("fingerprint", "", "the SHA256 fingerprint value of the ups' ssh server")
cfg.install.username = installFlags.StringLong("username", "", "username to login to the apc ups")
cfg.install.password = installFlags.StringLong("password", "", "password to login to the apc ups")
cfg.install.restartWebUI = installFlags.BoolLong("restartwebui", "some devices may need a webui restart to begin using the new cert, enabling this option sends the restart command after the p15 is installed")
cfg.install.webUISSLPort = installFlags.IntLong("sslport", 443, "apc ups ssl webui port number")
cfg.install.skipVerify = installFlags.BoolLong("skipverify", "the tool will try to connect to the UPS web UI to verify install success; this flag disables that check")
cfg.install.insecureCipher = installFlags.BoolLong("insecurecipher", "allows the use of insecure ssh ciphers (NOT recommended)")
installCmd := &ff.Command{
Name: "install",
Usage: "apc-p15-tool 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,

View file

@ -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

View file

@ -3,36 +3,63 @@ package app
import (
"apc-p15-tool/pkg/pkcs15"
"fmt"
"slices"
)
// pemToAPCP15 reads the specified pem files and returns the apc p15 bytes
func (app *app) pemToAPCP15(keyPem, certPem []byte, parentCmdName string) ([]byte, error) {
app.stdLogger.Printf("%s: making apc p15 file from pem", parentCmdName)
// list of keys supported by the NMC2
var nmc2SupportedKeyTypes = []pkcs15.KeyType{
pkcs15.KeyTypeRSA1024,
pkcs15.KeyTypeRSA2048,
pkcs15.KeyTypeRSA3072, // officially not supported but works
}
// pemToAPCP15 reads the specified pem files and returns the apc p15 file(s). If the
// key type of the key is not supported by NMC2, the combined key+cert file is not
// generated and nil is returned instead for that file. If the key IS supported by
// NMC2, the key+cert file is generated and the proper header is prepended.
func (app *app) pemToAPCP15(keyPem, certPem []byte, parentCmdName string) (keyFile []byte, apcKeyCertFile []byte, err error) {
app.stdLogger.Printf("%s: making apc p15 file(s) content from pem", parentCmdName)
// make p15 struct
p15, err := pkcs15.ParsePEMToPKCS15(keyPem, certPem)
if err != nil {
return nil, fmt.Errorf("%s: failed to parse pem files (%w)", parentCmdName, err)
return nil, nil, fmt.Errorf("%s: failed to parse pem files (%w)", parentCmdName, err)
}
app.stdLogger.Printf("%s: successfully loaded pem files", parentCmdName)
app.stdLogger.Printf("%s: successfully parsed pem files", parentCmdName)
// make file bytes
p15File, err := p15.ToP15File()
// make key file (always)
keyFile, err = p15.ToP15Key()
if err != nil {
return nil, fmt.Errorf("%s: failed to make p15 file (%w)", parentCmdName, err)
return nil, nil, fmt.Errorf("%s: failed to make p15 key file (%w)", parentCmdName, err)
}
// make header for file bytes
apcHeader, err := makeFileHeader(p15File)
if err != nil {
return nil, fmt.Errorf("%s: failed to make p15 file header (%w)", parentCmdName, err)
app.stdLogger.Printf("%s: successfully generated p15 key file content", parentCmdName)
// check key type for compat with NMC2
if slices.Contains(nmc2SupportedKeyTypes, p15.KeyType()) {
app.stdLogger.Printf("%s: key type is supported by NMC2, generating p15 key+cert file content...", parentCmdName)
// make file bytes
keyCertFile, err := p15.ToP15KeyCert()
if err != nil {
return nil, nil, fmt.Errorf("%s: failed to make p15 key+cert file content (%w)", parentCmdName, err)
}
// make header for file bytes
apcHeader, err := makeFileHeader(keyCertFile)
if err != nil {
return nil, nil, fmt.Errorf("%s: failed to make p15 key+cert file header (%w)", parentCmdName, err)
}
// combine header with file
apcKeyCertFile = append(apcHeader, keyCertFile...)
} else {
// NMC2 unsupported
app.stdLogger.Printf("%s: key type is not supported by NMC2, skipping p15 key+cert file content", parentCmdName)
}
// combine header with file
apcFile := append(apcHeader, p15File...)
app.stdLogger.Printf("%s: apc p15 file(s) data succesfully generated", parentCmdName)
app.stdLogger.Printf("%s: apc p15 file data succesfully generated", parentCmdName)
return apcFile, nil
return keyFile, apcKeyCertFile, nil
}

View file

@ -1,45 +0,0 @@
package app
import (
"errors"
"fmt"
"golang.org/x/crypto/ssh"
)
// sshResetUPSWebUI sends a command to the UPS to restart the WebUI. This
// command is supposed to be required to load the new cert, but that
// doesn't seem to be true (at least it isn't on my UPS). Adding the
// option though, in case other UPS might need it.
func sshResetUPSWebUI(client *ssh.Client) error {
// make session to use for restart command
session, err := client.NewSession()
if err != nil {
return fmt.Errorf("ssh: restart: failed to create session (%w)", err)
}
defer session.Close()
// start shell
err = session.Shell()
if err != nil {
return fmt.Errorf("ssh: restart: failed to start shell (%w)", err)
}
// execure reboot via SendRequest
payload := []byte("reboot -Y")
payloadLen := uint8(len(payload))
payload = append([]byte{0, 0, 0, payloadLen}, payload...)
ok, err := session.SendRequest("exec", true, payload)
if err != nil {
return fmt.Errorf("ssh: scp: failed to execute scp cmd (%w)", err)
}
if !ok {
return errors.New("ssh: scp: execute scp cmd not ok")
}
// don't read remote output, as nothing interesting actually outputs
// done
return nil
}

View file

@ -1,34 +0,0 @@
package app
import (
"bufio"
"fmt"
"io"
)
// sshCheckResponse reads the output from the remote and returns an error
// if the remote output was not 0
func sshCheckResponse(remoteOutPipe io.Reader) error {
buffer := make([]uint8, 1)
_, err := remoteOutPipe.Read(buffer)
if err != nil {
return fmt.Errorf("ssh: failed to make read output buffer (%w)", err)
}
responseType := buffer[0]
message := ""
if responseType > 0 {
bufferedReader := bufio.NewReader(remoteOutPipe)
message, err = bufferedReader.ReadString('\n')
if err != nil {
return fmt.Errorf("ssh: failed to read output buffer (%w)", err)
}
}
// if not 0 (aka OK)
if responseType != 0 {
return fmt.Errorf("ssh: remote returned error (%d: %s)", responseType, message)
}
return nil
}

View file

@ -1,105 +0,0 @@
package app
import (
"bytes"
"errors"
"fmt"
"io"
"path"
"time"
"golang.org/x/crypto/ssh"
)
// APC UPS won't except Go's SSH "Run()" command as the format isn't quite
// the same. Therefore, write a custom implementation to send the desired
// command instead of relying on something like github.com/bramvdbogaerde/go-scp
const (
sshScpP15Destination = "/ssl/defaultcert.p15"
sshScpP15PermissionsStr = "0600"
sshScpTimeout = 90 * time.Second
)
// sshScpSendFileToUPS sends the p15File to the APC UPS via the SCP protocol. it is
// automatically placed in the correct directory and will overwrite any existing
// file
func sshScpSendFileToUPS(client *ssh.Client, p15File []byte) error {
// make session to use for SCP
session, err := client.NewSession()
if err != nil {
return fmt.Errorf("ssh: scp: failed to create session (%w)", err)
}
defer session.Close()
// attach pipes
out, err := session.StdoutPipe()
if err != nil {
return err
}
w, err := session.StdinPipe()
if err != nil {
return err
}
defer w.Close()
// send execute cmd --
// build cmd to send as request
// Go implementation sends additional 0x22 bytes when using Run() (as
// compared to putty's scp tool). these additional bytes seem to cause the
// apc ups to fail execution of the command
payload := []byte(fmt.Sprintf("scp -q -t %s", sshScpP15Destination))
payloadLen := uint8(len(payload))
payload = append([]byte{0, 0, 0, payloadLen}, payload...)
ok, err := session.SendRequest("exec", true, payload)
if err != nil {
return fmt.Errorf("ssh: scp: failed to execute scp cmd (%w)", err)
}
if !ok {
return errors.New("ssh: scp: execute scp cmd not ok")
}
// check remote response
// Note: File upload may not work if the client doesn't actually read from
// the remote output.
err = sshCheckResponse(out)
if err != nil {
return fmt.Errorf("ssh: scp: failed to send scp cmd (bad remote response) (%w)", err)
}
// just file name (without path)
filename := path.Base(sshScpP15Destination)
// send file header
_, err = fmt.Fprintln(w, "C"+sshScpP15PermissionsStr, len(p15File), filename)
if err != nil {
return fmt.Errorf("ssh: scp: failed to send file info (%w)", err)
}
err = sshCheckResponse(out)
if err != nil {
return fmt.Errorf("ssh: scp: failed to send file info (bad remote response) (%w)", err)
}
// send actual file
_, err = io.Copy(w, bytes.NewReader(p15File))
if err != nil {
return fmt.Errorf("ssh: scp: failed to send file(%w)", err)
}
// send file end
_, err = fmt.Fprint(w, "\x00")
if err != nil {
return fmt.Errorf("ssh: scp: failed to send final 00 byte (%w)", err)
}
err = sshCheckResponse(out)
if err != nil {
return fmt.Errorf("ssh: scp: failed to send file (bad remote response) (%w)", err)
}
// done
return nil
}

View file

@ -21,14 +21,19 @@ const (
apcKEKIterations = 5000
)
// encryptedKeyEnvelope encrypts p15's rsa private key using the algorithms and
// params expected in the APC file. Salt values are always random.
func (p15 *pkcs15KeyCert) encryptedKeyEnvelope() ([]byte, error) {
// encryptedKeyEnvelope encrypts p15's private key using the algorithms and
// params expected in the APC file.
func (p15 *pkcs15KeyCert) computeEncryptedKeyEnvelope() error {
// if computation already performed, this is a no-op (keep existing envelope)
if p15.envelopedPrivateKey != nil && len(p15.envelopedPrivateKey) != 0 {
return nil
}
// calculate values for the object
kekSalt := make([]byte, 8)
_, err := rand.Read(kekSalt)
if err != nil {
return nil, err
return err
}
// kek hash alg
@ -42,7 +47,7 @@ func (p15 *pkcs15KeyCert) encryptedKeyEnvelope() ([]byte, error) {
// make DES cipher from KEK for CEK
cekDesCipher, err := des.NewTripleDESCipher(kek)
if err != nil {
return nil, err
return err
}
// cek (16 bytes for authEnc128) -- see: rfc3211
@ -50,7 +55,7 @@ func (p15 *pkcs15KeyCert) encryptedKeyEnvelope() ([]byte, error) {
cek := make([]byte, cekLen)
_, err = rand.Read(cek)
if err != nil {
return nil, err
return err
}
// LEN + Check Val [3]
@ -71,7 +76,7 @@ func (p15 *pkcs15KeyCert) encryptedKeyEnvelope() ([]byte, error) {
cekPadding := make([]byte, cekPadLen)
_, err = rand.Read(cekPadding)
if err != nil {
return nil, err
return err
}
wrappedCEK = append(wrappedCEK, cekPadding...)
@ -80,7 +85,7 @@ func (p15 *pkcs15KeyCert) encryptedKeyEnvelope() ([]byte, error) {
cekEncryptSalt := make([]byte, 8)
_, err = rand.Read(cekEncryptSalt)
if err != nil {
return nil, err
return err
}
cekEncrypter := cipher.NewCBCEncrypter(cekDesCipher, cekEncryptSalt)
@ -94,13 +99,13 @@ func (p15 *pkcs15KeyCert) encryptedKeyEnvelope() ([]byte, error) {
contentEncSalt := make([]byte, 8)
_, err = rand.Read(contentEncSalt)
if err != nil {
return nil, err
return err
}
contentEncryptKey := pbkdf2.Key(cek, []byte("encryption"), 1, 24, sha1.New)
contentDesCipher, err := des.NewTripleDESCipher(contentEncryptKey)
if err != nil {
return nil, err
return err
}
// envelope content (that will be encrypted)
@ -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
}

View file

@ -2,6 +2,8 @@ package pkcs15
import (
"apc-p15-tool/pkg/tools/asn1obj"
"crypto/ecdsa"
"crypto/rsa"
"crypto/sha1"
"encoding/binary"
"math/big"
@ -11,22 +13,6 @@ import (
func (p15 *pkcs15KeyCert) keyId() []byte {
// object to hash is just the RawSubjectPublicKeyInfo
// Create Object to hash
// hashObj := asn1obj.Sequence([][]byte{
// asn1obj.Sequence([][]byte{
// // Key is RSA
// asn1obj.ObjectIdentifier(asn1obj.OIDrsaEncryptionPKCS1),
// asn1.NullBytes,
// }),
// // BIT STRING of rsa key public key
// asn1obj.BitString(
// asn1obj.Sequence([][]byte{
// asn1obj.Integer(p15.key.N),
// asn1obj.Integer((big.NewInt(int64(p15.key.E)))),
// }),
// ),
// })
// SHA-1 Hash
hasher := sha1.New()
_, err := hasher.Write(p15.cert.RawSubjectPublicKeyInfo)
@ -124,18 +110,32 @@ func (p15 *pkcs15KeyCert) keyIdInt7() []byte {
}
// keyIdInt8 returns the sequence for keyId with INT val of 8; This value is equivelant
// to "pgp", which is PGP v3 key Id. This value is just the last 8 bytes of the public
// key N value
// to "pgp", which is PGP v3 key Id.
func (p15 *pkcs15KeyCert) keyIdInt8() []byte {
nBytes := p15.key.N.Bytes()
var keyIdVal []byte
switch privKey := p15.key.(type) {
case *rsa.PrivateKey:
// RSA: The ID value is just the last 8 bytes of the public key N value
nBytes := privKey.N.Bytes()
keyIdVal = nBytes[len(nBytes)-8:]
case *ecdsa.PrivateKey:
// don't use this key id, leave empty
return nil
default:
// panic if unexpected key type
panic("key id 8 for key is unexpected and unsupported")
}
// object to return
obj := asn1obj.Sequence([][]byte{
idObj := asn1obj.Sequence([][]byte{
asn1obj.Integer(big.NewInt(8)),
asn1obj.OctetString(nBytes[len(nBytes)-8:]),
asn1obj.OctetString(keyIdVal),
})
return obj
return idObj
}
// bigIntToMpi returns the MPI (as defined in RFC 4880 s 3.2) from a given
@ -156,33 +156,44 @@ func (p15 *pkcs15KeyCert) keyIdInt9() []byte {
// Public-Key packet starting with the version field. The Key ID is the
// low-order 64 bits of the fingerprint.
// the entire Public-Key packet
// first make the public key packet
publicKeyPacket := []byte{}
// starting with the version field (A one-octet version number (4)).
publicKeyPacket = append(publicKeyPacket, byte(4))
// A four-octet number denoting the time that the key was created.
time := make([]byte, 4)
// NOTE: use cert validity start as proxy for key creation since key pem
// doesn't actually contain a created at time -- in reality notBefore tends
// to be ~ 1 hour ish BEFORE the cert was even created. Key would also
// obviously have to be created prior to the cert creation.
time := make([]byte, 4)
binary.BigEndian.PutUint32(time, uint32(p15.cert.NotBefore.Unix()))
publicKeyPacket = append(publicKeyPacket, time...)
// A one-octet number denoting the public-key algorithm of this key.
// 1 - RSA (Encrypt or Sign) [HAC]
publicKeyPacket = append(publicKeyPacket, byte(1))
// the next part is key type specific
switch privKey := p15.key.(type) {
case *rsa.PrivateKey:
// A one-octet number denoting the public-key algorithm of this key.
// 1 - RSA (Encrypt or Sign) [HAC]
publicKeyPacket = append(publicKeyPacket, byte(1))
// Algorithm-Specific Fields for RSA public keys:
// multiprecision integer (MPI) of RSA public modulus n
publicKeyPacket = append(publicKeyPacket, bigIntToMpi(p15.key.N)...)
// Algorithm-Specific Fields for RSA public keys:
// multiprecision integer (MPI) of RSA public modulus n
publicKeyPacket = append(publicKeyPacket, bigIntToMpi(privKey.N)...)
// MPI of RSA public encryption exponent e
e := big.NewInt(int64(p15.key.PublicKey.E))
publicKeyPacket = append(publicKeyPacket, bigIntToMpi(e)...)
// MPI of RSA public encryption exponent e
e := big.NewInt(int64(privKey.PublicKey.E))
publicKeyPacket = append(publicKeyPacket, bigIntToMpi(e)...)
case *ecdsa.PrivateKey:
// don't use this key id, leave empty
return nil
default:
// panic if unexpected key type
panic("key id 9 for key is unexpected and unsupported")
}
// Assemble the V4 byte array that will be hashed
// 0x99 (1 octet)
@ -205,10 +216,10 @@ func (p15 *pkcs15KeyCert) keyIdInt9() []byte {
keyId := sha1Hash[len(sha1Hash)-8:]
// object to return
obj := asn1obj.Sequence([][]byte{
idObj := asn1obj.Sequence([][]byte{
asn1obj.Integer(big.NewInt(9)),
asn1obj.OctetString(keyId),
})
return obj
return idObj
}

View file

@ -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

View file

@ -1,6 +1,8 @@
package pkcs15
import (
"crypto"
"crypto/ecdsa"
"crypto/rsa"
"crypto/x509"
)
@ -8,8 +10,61 @@ import (
// pkcs15KeyCert holds the data for a key and certificate pair; it provides
// various methods to transform pkcs15 data
type pkcs15KeyCert struct {
key *rsa.PrivateKey
key crypto.PrivateKey
cert *x509.Certificate
// store the encrypted enveloped Private Key for re-use
envelopedPrivateKey []byte
}
// KeyType is used by consumers to check for compatibility
type KeyType int
const (
KeyTypeRSA1024 KeyType = iota
KeyTypeRSA2048
KeyTypeRSA3072
KeyTypeRSA4096
KeyTypeECP256
KeyTypeECP384
KeyTypeECP521
KeyTypeUnknown
)
// KeyType returns the private key type
func (p15 *pkcs15KeyCert) KeyType() KeyType {
switch pKey := p15.key.(type) {
case *rsa.PrivateKey:
switch pKey.N.BitLen() {
case 1024:
return KeyTypeRSA1024
case 2048:
return KeyTypeRSA2048
case 3072:
return KeyTypeRSA3072
case 4096:
return KeyTypeRSA4096
default:
}
case *ecdsa.PrivateKey:
switch pKey.Curve.Params().Name {
case "P-256":
return KeyTypeECP256
case "P-384":
return KeyTypeECP384
case "P-521":
return KeyTypeECP521
default:
}
default:
}
return KeyTypeUnknown
}
// ParsePEMToPKCS15 parses the provide pem files to a pkcs15 struct; it also does some
@ -27,10 +82,17 @@ func ParsePEMToPKCS15(keyPem, certPem []byte) (*pkcs15KeyCert, error) {
return nil, err
}
// create p15 struct
p15 := &pkcs15KeyCert{
key: key,
cert: cert,
}
// pre-calculate encrypted envelope
err = p15.computeEncryptedKeyEnvelope()
if err != nil {
return nil, err
}
return p15, nil
}

View file

@ -2,6 +2,10 @@ package pkcs15
import (
"apc-p15-tool/pkg/tools/asn1obj"
"crypto/ecdsa"
"crypto/rsa"
"encoding/asn1"
"fmt"
"math/big"
)
@ -9,100 +13,89 @@ const (
apcKeyLabel = "Private key"
)
// ToP15File turns the key and cert into a properly formatted and encoded
// p15 file
func (p15 *pkcs15KeyCert) ToP15File() ([]byte, error) {
// private key object
pkey, err := p15.toP15PrivateKey()
// toP15KeyCert creates a P15 file with both the private key and certificate, mirroring the
// final p15 file an APC UPS expects (though without the header)
func (p15 *pkcs15KeyCert) ToP15KeyCert() (keyCert []byte, err error) {
// encrypted envelope is required
err = p15.computeEncryptedKeyEnvelope()
if err != nil {
return nil, err
}
cert, err := p15.toP15Cert()
if err != nil {
return nil, err
}
// create private key object
var privKeyObj []byte
// ContentInfo
p15File := asn1obj.Sequence([][]byte{
// contentType: OID: 1.2.840.113549.1.15.3.1 pkcs15content (PKCS #15 content type)
asn1obj.ObjectIdentifier(asn1obj.OIDPkscs15Content),
// content
asn1obj.ExplicitCompound(0, [][]byte{
switch p15.key.(type) {
case *rsa.PrivateKey:
// private key object
privKeyObj =
asn1obj.Sequence([][]byte{
asn1obj.Integer(big.NewInt(0)),
// commonObjectAttributes - Label
asn1obj.Sequence([][]byte{
asn1obj.ExplicitCompound(0, [][]byte{
asn1obj.ExplicitCompound(0, [][]byte{
pkey,
}),
}),
asn1obj.ExplicitCompound(4, [][]byte{
asn1obj.ExplicitCompound(0, [][]byte{
cert,
asn1obj.UTF8String(apcKeyLabel),
}),
// CommonKeyAttributes
asn1obj.Sequence([][]byte{
// CommonKeyAttributes - iD - uses keyId that is SHA1( SubjectPublicKeyInfo SEQUENCE )
asn1obj.OctetString(p15.keyId()),
// CommonKeyAttributes - usage (trailing 0s will drop)
asn1obj.BitString([]byte{byte(0b11100010)}),
// CommonKeyAttributes - accessFlags (trailing 0s will drop)
asn1obj.BitString([]byte{byte(0b10110000)}),
// CommonKeyAttributes - startDate
asn1obj.GeneralizedTime(p15.cert.NotBefore),
// CommonKeyAttributes - [0] endDate
asn1obj.GeneralizedTimeExplicitValue(0, p15.cert.NotAfter),
}),
// ObjectValue - indirect-protected
asn1obj.ExplicitCompound(1, [][]byte{
asn1obj.Sequence([][]byte{
// AuthEnvelopedData Type ([4])
asn1obj.ExplicitCompound(4, [][]byte{
p15.envelopedPrivateKey,
}),
}),
}),
}),
}),
})
})
return p15File, nil
}
case *ecdsa.PrivateKey:
privKeyObj =
asn1obj.ExplicitCompound(0, [][]byte{
// commonObjectAttributes - Label
asn1obj.Sequence([][]byte{
asn1obj.UTF8String(apcKeyLabel),
}),
// CommonKeyAttributes
asn1obj.Sequence([][]byte{
// CommonKeyAttributes - iD - uses keyId that is SHA1( SubjectPublicKeyInfo SEQUENCE )
asn1obj.OctetString(p15.keyId()),
// CommonKeyAttributes - usage (trailing 0s will drop)
asn1obj.BitString([]byte{byte(0b00100010)}),
// CommonKeyAttributes - accessFlags (trailing 0s will drop)
asn1obj.BitString([]byte{byte(0b10110000)}),
// CommonKeyAttributes - startDate
asn1obj.GeneralizedTime(p15.cert.NotBefore),
// CommonKeyAttributes - [0] endDate
asn1obj.GeneralizedTimeExplicitValue(0, p15.cert.NotAfter),
}),
// ObjectValue - indirect-protected
asn1obj.ExplicitCompound(1, [][]byte{
asn1obj.Sequence([][]byte{
// AuthEnvelopedData Type ([4])
asn1obj.ExplicitCompound(4, [][]byte{
p15.envelopedPrivateKey,
}),
}),
}),
})
// toP15PrivateKey creates the encoded private key. it is broken our from the larger p15
// function for readability
// NOTE: Do not use this to try and turn just a private key into a p15, the format isn't
// quite the same.
func (p15 *pkcs15KeyCert) toP15PrivateKey() ([]byte, error) {
// rsa encrypted key in encrypted envelope
envelope, err := p15.encryptedKeyEnvelope()
if err != nil {
return nil, err
default:
// bad key type
return nil, errKeyWrongType
}
// key object
key := asn1obj.Sequence([][]byte{
// commonObjectAttributes - Label
asn1obj.Sequence([][]byte{
asn1obj.UTF8String(apcKeyLabel),
}),
// CommonKeyAttributes
asn1obj.Sequence([][]byte{
// CommonKeyAttributes - iD - uses keyId that is SHA1( SubjectPublicKeyInfo SEQUENCE )
asn1obj.OctetString(p15.keyId()),
// CommonKeyAttributes - usage (trailing 0s will drop)
asn1obj.BitString([]byte{byte(0b11100010)}),
// CommonKeyAttributes - accessFlags (trailing 0s will drop)
asn1obj.BitString([]byte{byte(0b10110000)}),
// CommonKeyAttributes - startDate
asn1obj.GeneralizedTime(p15.cert.NotBefore),
// CommonKeyAttributes - [0] endDate
asn1obj.GeneralizedTimeExplicitValue(0, p15.cert.NotAfter),
}),
// ObjectValue - indirect-protected
asn1obj.ExplicitCompound(1, [][]byte{
asn1obj.Sequence([][]byte{
// AuthEnvelopedData Type ([4])
asn1obj.ExplicitCompound(4, [][]byte{
envelope,
}),
}),
}),
})
return key, nil
}
// toP15Cert creates the encoded certificate. it is broken our from the larger p15
// function for readability
// NOTE: Do not use this to try and turn just a cert into a p15. I don't believe,
// such a thing is permissible under the spec.
func (p15 *pkcs15KeyCert) toP15Cert() ([]byte, error) {
// cert object
cert := asn1obj.Sequence([][]byte{
certObj := asn1obj.Sequence([][]byte{
// commonObjectAttributes - Label
asn1obj.Sequence([][]byte{
asn1obj.UTF8String(apcKeyLabel),
@ -116,6 +109,7 @@ func (p15 *pkcs15KeyCert) toP15Cert() ([]byte, error) {
p15.keyIdInt3(),
p15.keyIdInt6(),
p15.keyIdInt7(),
// 8 & 9 will return nil for EC keys (effectively omitting them)
p15.keyIdInt8(),
p15.keyIdInt9(),
}),
@ -134,5 +128,246 @@ func (p15 *pkcs15KeyCert) toP15Cert() ([]byte, error) {
}),
})
return cert, nil
// build the object
// ContentInfo
keyCert = asn1obj.Sequence([][]byte{
// contentType: OID: 1.2.840.113549.1.15.3.1 pkcs15content (PKCS #15 content type)
asn1obj.ObjectIdentifier(asn1obj.OIDPkscs15Content),
// content
asn1obj.ExplicitCompound(0, [][]byte{
asn1obj.Sequence([][]byte{
asn1obj.Integer(big.NewInt(0)),
asn1obj.Sequence([][]byte{
asn1obj.ExplicitCompound(0, [][]byte{
asn1obj.ExplicitCompound(0, [][]byte{
privKeyObj,
}),
}),
asn1obj.ExplicitCompound(4, [][]byte{
asn1obj.ExplicitCompound(0, [][]byte{
certObj,
}),
}),
}),
}),
}),
})
return keyCert, nil
}
// toP15Key creates a P15 file with just the private key, mirroring the p15 format
// the APC tool uses when generating a new private key (Note: no header is used on
// this file)
func (p15 *pkcs15KeyCert) ToP15Key() (key []byte, err error) {
// encrypted envelope is required
err = p15.computeEncryptedKeyEnvelope()
if err != nil {
return nil, err
}
// create private and public key objects
var pubKeyObj, privKeyObj []byte
switch privKey := p15.key.(type) {
case *rsa.PrivateKey:
// private key object (slightly different than the key+cert format)
privKeyObj =
asn1obj.Sequence([][]byte{
// commonObjectAttributes - Label
asn1obj.Sequence([][]byte{
asn1obj.UTF8String(apcKeyLabel),
}),
// CommonKeyAttributes
asn1obj.Sequence([][]byte{
// CommonKeyAttributes - iD - uses keyId that is SHA1( SubjectPublicKeyInfo SEQUENCE )
asn1obj.OctetString(p15.keyId()),
// CommonKeyAttributes - usage (trailing 0s will drop)
asn1obj.BitString([]byte{byte(0b11100010)}),
// CommonKeyAttributes - accessFlags (trailing 0s will drop)
asn1obj.BitString([]byte{byte(0b10110000)}),
}),
// Key IDs
asn1obj.ExplicitCompound(0, [][]byte{
asn1obj.Sequence([][]byte{
asn1obj.ExplicitCompound(0, [][]byte{
p15.keyIdInt2(),
p15.keyIdInt8(),
p15.keyIdInt9(),
}),
}),
}),
// ObjectValue - indirect-protected
asn1obj.ExplicitCompound(1, [][]byte{
asn1obj.Sequence([][]byte{
// AuthEnvelopedData Type ([4])
asn1obj.ExplicitCompound(4, [][]byte{
p15.envelopedPrivateKey,
}),
}),
}),
})
// pub key stub
pubKeyObj =
asn1obj.Sequence([][]byte{
// commonObjectAttributes - Label
asn1obj.Sequence([][]byte{
asn1obj.UTF8String(apcKeyLabel),
}),
// CommonKeyAttributes
asn1obj.Sequence([][]byte{
asn1obj.OctetString(p15.keyId()),
asn1obj.BitString([]byte{byte(0b10000010)}),
asn1obj.BitString([]byte{byte(0b01000000)}),
}),
asn1obj.ExplicitCompound(1, [][]byte{
asn1obj.Sequence([][]byte{
asn1obj.ExplicitCompound(0, [][]byte{
asn1obj.ExplicitCompound(1, [][]byte{
asn1obj.Sequence([][]byte{
asn1obj.ObjectIdentifier(asn1obj.OIDrsaEncryptionPKCS1),
asn1.NullBytes,
}),
// RSAPublicKey SubjectPublicKeyInfo
asn1obj.BitString(
asn1obj.Sequence([][]byte{
asn1obj.Integer(privKey.PublicKey.N),
asn1obj.Integer(big.NewInt(int64(privKey.PublicKey.E))),
}),
),
}),
}),
// not 100% certain but appears to be rsa key byte len
asn1obj.Integer(big.NewInt(int64(privKey.PublicKey.N.BitLen() / 8))),
}),
}),
})
case *ecdsa.PrivateKey:
// private key object (slightly different than the key+cert format)
privKeyObj =
asn1obj.ExplicitCompound(0, [][]byte{
// commonObjectAttributes - Label
asn1obj.Sequence([][]byte{
asn1obj.UTF8String(apcKeyLabel),
}),
// CommonKeyAttributes
asn1obj.Sequence([][]byte{
// CommonKeyAttributes - iD - uses keyId that is SHA1( SubjectPublicKeyInfo SEQUENCE )
asn1obj.OctetString(p15.keyId()),
// CommonKeyAttributes - usage (trailing 0s will drop)
asn1obj.BitString([]byte{byte(0b00100010)}),
// CommonKeyAttributes - accessFlags (trailing 0s will drop)
asn1obj.BitString([]byte{byte(0b10110000)}),
}),
// Key IDs
asn1obj.ExplicitCompound(0, [][]byte{
asn1obj.Sequence([][]byte{
asn1obj.ExplicitCompound(0, [][]byte{
p15.keyIdInt2(),
}),
}),
}),
// ObjectValue - indirect-protected
asn1obj.ExplicitCompound(1, [][]byte{
asn1obj.Sequence([][]byte{
// AuthEnvelopedData Type ([4])
asn1obj.ExplicitCompound(4, [][]byte{
p15.envelopedPrivateKey,
}),
}),
}),
})
// convert ec pub key to a form that provides a public key bytes function
ecdhKey, err := privKey.PublicKey.ECDH()
if err != nil {
return nil, fmt.Errorf("failed to parse ec public key (%s)", err)
}
// select correct OID for curve
var curveOID asn1.ObjectIdentifier
switch privKey.Curve.Params().Name {
case "P-256":
curveOID = asn1obj.OIDprime256v1
case "P-384":
curveOID = asn1obj.OIDsecp384r1
case "P-521":
curveOID = asn1obj.OIDsecp521r1
default:
// bad curve name
return nil, errKeyWrongType
}
// pub key stub
pubKeyObj =
asn1obj.ExplicitCompound(0, [][]byte{
// commonObjectAttributes - Label
asn1obj.Sequence([][]byte{
asn1obj.UTF8String(apcKeyLabel),
}),
// CommonKeyAttributes
asn1obj.Sequence([][]byte{
asn1obj.OctetString(p15.keyId()),
asn1obj.BitString([]byte{byte(0b00000010)}),
asn1obj.BitString([]byte{byte(0b01000000)}),
}),
asn1obj.ExplicitCompound(1, [][]byte{
asn1obj.Sequence([][]byte{
asn1obj.ExplicitCompound(0, [][]byte{
asn1obj.Sequence([][]byte{
asn1obj.Sequence([][]byte{
asn1obj.ObjectIdentifier(asn1obj.OIDecPublicKey),
asn1obj.ObjectIdentifier(curveOID),
}),
asn1obj.BitString(ecdhKey.Bytes()),
}),
}),
}),
}),
})
default:
// bad key type
return nil, errKeyWrongType
}
// assemble complete object
key =
asn1obj.Sequence([][]byte{
// contentType: OID: 1.2.840.113549.1.15.3.1 pkcs15content (PKCS #15 content type)
asn1obj.ObjectIdentifier(asn1obj.OIDPkscs15Content),
// content
asn1obj.ExplicitCompound(0, [][]byte{
asn1obj.Sequence([][]byte{
asn1obj.Integer(big.NewInt(0)),
asn1obj.Sequence([][]byte{
// [0] Private Keys
asn1obj.ExplicitCompound(0, [][]byte{
asn1obj.ExplicitCompound(0, [][]byte{
privKeyObj,
}),
}),
// [1] Public Keys
asn1obj.ExplicitCompound(1, [][]byte{
asn1obj.ExplicitCompound(0, [][]byte{
pubKeyObj,
}),
}),
}),
}),
}),
})
return key, nil
}

View file

@ -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
}

View file

@ -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