diff --git a/.github/workflows/build_releases.yml b/.github/workflows/build_releases.yml
index d1a0d90..5b41dbe 100644
--- a/.github/workflows/build_releases.yml
+++ b/.github/workflows/build_releases.yml
@@ -8,11 +8,11 @@ on:
 
 env:
   GITHUB_REF: ${{ github.ref }}
-  GO_VERSION: '1.22.1'
+  GO_VERSION: '1.24.2'
 
 jobs:
   build-common:
-    runs-on: ubuntu-latest
+    runs-on: ubuntu-24.04
 
     steps:
       - name: Checkout Main Repo
@@ -40,8 +40,10 @@ jobs:
           name: CHANGELOG.md
           path: ./CHANGELOG.md
 
+###
+
   build-linux-arm64:
-    runs-on: ubuntu-latest
+    runs-on: ubuntu-24.04-arm
     steps:
       - name: Checkout Repo
         uses: actions/checkout@v4
@@ -50,12 +52,6 @@ jobs:
           ref: ${{ env.GITHUB_REF }}
           fetch-depth: 0
 
-      - name: Update apt
-        run: sudo apt update
-
-      - name: Install cross-compiler for linux/arm64
-        run: sudo apt-get -y install gcc-aarch64-linux-gnu
-
       - name: Set up Go
         uses: actions/setup-go@v5
         with:
@@ -66,7 +62,6 @@ jobs:
         env:
           GOOS: linux
           GOARCH: arm64
-          CC: aarch64-linux-gnu-gcc
           CGO_ENABLED: 0
 
       - name: Save Compiled Binary
@@ -90,7 +85,7 @@ jobs:
           path: ./apc-p15-install
 
   build-linux-amd64:
-    runs-on: ubuntu-latest
+    runs-on: ubuntu-24.04
     steps:
       - name: Checkout Backend Repo
         uses: actions/checkout@v4
@@ -171,9 +166,93 @@ jobs:
           name: apc-p15-install-windows-amd64
           path: ./apc-p15-install.exe
 
+  build-darwin-arm64:
+    runs-on: macos-15
+    steps:
+      - name: Checkout Backend Repo
+        uses: actions/checkout@v4
+        with:
+          repository: gregtwallace/apc-p15-tool
+          ref: ${{ env.GITHUB_REF }}
+          fetch-depth: 0
+
+      - name: Set up Go
+        uses: actions/setup-go@v5
+        with:
+          go-version: '${{ env.GO_VERSION }}'
+
+      - name: Build Tool
+        run: go build -o ./apc-p15-tool -v ./cmd/tool
+        env:
+          GOOS: darwin
+          GOARCH: arm64
+          CGO_ENABLED: 0
+
+      - name: Save Compiled Binary
+        uses: actions/upload-artifact@v4
+        with:
+          name: apc-p15-tool-darwin-arm64
+          path: ./apc-p15-tool
+
+      - name: Build Install Only
+        run: go build -o ./apc-p15-install -v ./cmd/install_only
+        env:
+          GOOS: darwin
+          GOARCH: arm64
+          CGO_ENABLED: 0
+
+      - name: Save Compiled Binary
+        uses: actions/upload-artifact@v4
+        with:
+          name: apc-p15-install-darwin-arm64
+          path: ./apc-p15-install
+
+  build-darwin-amd64:
+    runs-on: macos-13
+    steps:
+      - name: Checkout Backend Repo
+        uses: actions/checkout@v4
+        with:
+          repository: gregtwallace/apc-p15-tool
+          ref: ${{ env.GITHUB_REF }}
+          fetch-depth: 0
+
+      - name: Set up Go
+        uses: actions/setup-go@v5
+        with:
+          go-version: '${{ env.GO_VERSION }}'
+
+      - name: Build Tool
+        run: go build -o ./apc-p15-tool -v ./cmd/tool
+        env:
+          GOOS: darwin
+          GOARCH: amd64
+          CGO_ENABLED: 0
+
+      - name: Save Compiled Binary
+        uses: actions/upload-artifact@v4
+        with:
+          name: apc-p15-tool-darwin-amd64
+          path: ./apc-p15-tool
+
+      - name: Build Install Only
+        run: go build -o ./apc-p15-install -v ./cmd/install_only
+        env:
+          GOOS: darwin
+          GOARCH: amd64
+          CGO_ENABLED: 0
+
+      - name: Save Compiled Binary
+        uses: actions/upload-artifact@v4
+        with:
+          name: apc-p15-install-darwin-amd64
+          path: ./apc-p15-install
+
+###
+
   release-file-linux-arm64:
     needs: [build-common, build-linux-arm64]
-    runs-on: ubuntu-latest
+    runs-on: ubuntu-24.04
 
     steps:
       - name: Make release directory
@@ -217,7 +296,7 @@ jobs:
 
   release-file-linux-amd64:
     needs: [build-common, build-linux-amd64]
-    runs-on: ubuntu-latest
+    runs-on: ubuntu-24.04
 
     steps:
       - name: Make release directory
@@ -261,7 +340,7 @@ jobs:
 
   release-file-windows-amd64:
     needs: [build-common, build-windows-amd64]
-    runs-on: ubuntu-latest
+    runs-on: ubuntu-24.04
 
     steps:
       - name: Make release directory
@@ -302,3 +381,91 @@ jobs:
         with:
           name: apc-p15-tool_windows_amd64
           path: ./release
+
+  release-file-darwin-arm64:
+    needs: [build-common, build-darwin-arm64]
+    runs-on: ubuntu-24.04
+
+    steps:
+      - name: Make release directory
+        run: mkdir ./release
+
+      - name: Download Tool Binary
+        uses: actions/download-artifact@v4
+        with:
+          name: apc-p15-tool-darwin-arm64
+          path: ./release
+
+      - name: Download Install Binary
+        uses: actions/download-artifact@v4
+        with:
+          name: apc-p15-install-darwin-arm64
+          path: ./release
+
+      - name: Download README
+        uses: actions/download-artifact@v4
+        with:
+          name: README.md
+          path: ./release
+
+      - name: Download LICENSE
+        uses: actions/download-artifact@v4
+        with:
+          name: LICENSE.md
+          path: ./release
+
+      - name: Download CHANGELOG
+        uses: actions/download-artifact@v4
+        with:
+          name: CHANGELOG.md
+          path: ./release
+
+      - name: Save Release
+        uses: actions/upload-artifact@v4
+        with:
+          name: apc-p15-tool_darwin_arm64
+          path: ./release
+
+  release-file-darwin-amd64:
+    needs: [build-common, build-darwin-amd64]
+    runs-on: ubuntu-24.04
+
+    steps:
+      - name: Make release directory
+        run: mkdir ./release
+
+      - name: Download Tool Binary
+        uses: actions/download-artifact@v4
+        with:
+          name: apc-p15-tool-darwin-amd64
+          path: ./release
+
+      - name: Download Install Binary
+        uses: actions/download-artifact@v4
+        with:
+          name: apc-p15-install-darwin-amd64
+          path: ./release
+
+      - name: Download README
+        uses: actions/download-artifact@v4
+        with:
+          name: README.md
+          path: ./release
+
+      - name: Download LICENSE
+        uses: actions/download-artifact@v4
+        with:
+          name: LICENSE.md
+          path: ./release
+
+      - name: Download CHANGELOG
+        uses: actions/download-artifact@v4
+        with:
+          name: CHANGELOG.md
+          path: ./release
+
+      - name: Save Release
+        uses: actions/upload-artifact@v4
+        with:
+          name: apc-p15-tool_darwin_amd64
+          path: ./release
diff --git a/.gitignore b/.gitignore
index 9e11ecb..6cd9c9f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
 # key/cert files
 *.p15
 *.pem
+*.b64
 
 # ignore test_data folder
 /_test_data
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2232bd5..cc534a6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/README.md b/README.md
index 10ea261..1d494ed 100644
--- a/README.md
+++ b/README.md
@@ -1,30 +1,106 @@
 # APC P15 Tool
-A tool to create APC p15 formatted certificates from pem files, without
-having to use APC's closed-source tool, APC generated keys, or other 
-proprietary tools (such as cryptlib).
+
+APC P15 Tool is a completely open source application designed to make 
+creating and installing SSL certificates on APC (Schneider Electric) 
+Network Management Cards (2 & 3) simple and easy to do. It is also 
+designed to simplify automation of the certificate management lifecycle.
+
+## Background
+
+When APC created the NMC2 (Network Management Card 2), they chose to use 
+the p15 file format for their SSL keys and certificates, which is a 
+relatively obscure file format. In addition to this, they designed the
+device to require an APC specific header be prepended to the p15 file
+or the file would be rejected by the device. Accordingly, they created 
+a proprietary tool (the `NMC Security Wizard CLI Utility`) to generate
+the required format.
+
+Unfortunately, the proprietary tool has a number of shortcomings:
+- It can be difficult to find the right version to use. APC has released
+  a number of versions (in both a CLI and GUI form). Not all of the
+  versions worked correctly (or at all).
+- User provided private keys are not supported. Private keys must be 
+  generated by the proprietary tool and are only outputted in the p15 
+  format. APC's proprietary tool is closed source and as such there is 
+  no way to audit the key generation process.
+- Since the generated keys are in the p15 format, they can't be loaded
+  easily into other management tools (such as Cert Warden
+  https://www.certwarden.com/), nor can CSRs be generated easily 
+  outside of the proprietary tool. The proprietary tool is generally
+  required to generate the CSR.
+- The CSR generation function in the proprietary tool is fairly rigid,
+  making customization (e.g., multiple DNS names) difficult, if not 
+  impossible.
+- After the user generates a key, generates a CSR, sends that CSR to 
+  their CA, and receives a certificate back, they're still not done.
+  The tool must be used again to generate the final p15 file for the
+  NMC.
+- To install the final file on the NMC, the user must use an SCP
+  program such as `pscp` to install the file, or the NMC's web UI.
+
+Due to all of this, others have tried to recreate the proprietary
+functionality. The only implementations I have found rely on a closed
+source library called `cryptlib`. This library has evolved over time
+and more recent versions do not work for the NMC (it appears at some
+point cryptlib switched from 3DES to AES and NMC does not support
+AES within the p15 file). It was also near impossible to find an old
+enough version of cryptlib that would work. Even if one gets this 
+working, it does not resolve the obscurity of a closed source 
+implementation and would continue to be subject to potential future
+breakage as the cryptlib library continues to evolve.
+
+This project aims to solve all of these problems by accepting the most
+common key and cert file format (PEM) and by being 100% open source
+and licensed under the GPL-3.0 license.
 
 ## Compatibility Notice
 
-This tool's create functionality is modeled from the APC NMCSecurityWizardCLI 
-aka `NMC Security Wizard CLI Utility`. The files it generates should be 
-comaptible with any UPS that accepts p15 files from that tool. Only RSA 1,024
-and 2,048 bit keys are accepted. 1,024 bit RSA is no longer considered 
-completely secure; avoid keys of this size if possible. Most (all?) public 
-ACME services won't accept keys of this size anyway.
+Both NMC2 and NMC3 devices should be fully supported. However, I have one
+NMC2 device in a home lab and have no way to guarantee success in all cases.
 
-The install functionality is a custom creation of mine so it may or may not 
-work depending on your exact setup. My setup (and therefore the testing 
-setup) is:
+### Key Types and Sizes
+
+NMC2:
+- RSA 1,024, 2,048, 3,072* bit lengths.
+
+NMC3*:
+- RSA 1,024, 2,048, 3,072, and 4,092 bit lengths.
+- ECDSA curves P-256, P-384, and P-521. 
+
+\* 3,072 bit length is not officially supported by my NMC2, but appears to work
+  fine.
+
+\* The additional key types supported by NMC3 require newer firmware on the
+  device. I am unsure what the version cutoff is, but you can check support
+  by connecting to the UPS via SSH and typing `ssl`. If `Command Not Found`
+  is returned, the firmware is too old and only the key types listed under
+  NMC2 will work.
+
+1,024 bit RSA is no longer considered completely secure; avoid keys of 
+this size if possible. Most (all?) public ACME services won't accept keys 
+of this size anyway.
+
+### General Troubleshooting
+
+My setup (and therefore the testing setup) is:
 - APC Smart-UPS 1500VA RM 2U SUA1500RM2U (Firmware Revision 667.18.D)
-- AP9631 NMC2 Hardware Revision 05 running AOS v7.0.4 and Boot Monitor 
+- AP9631 NMC2 Hardware Revision 05 running AOS v7.1.2 and Boot Monitor 
   v1.0.9.
 
-If you have problems you can post the log in an issue and I can try to fix it
-but it may be difficult without your particular hardware to test with.
+If you have trouble, your first step should be to update your NMC's firmware.
+Many issues with this tool will be resolved simply by updating to the newest
+firmware.
 
-In particular, if you are experiencing `ssh: handshake failed:` please run 
-`ssh -vv myups.example.com` and include the `peer server KEXINIT proposal`
-in your issue. For example:
+If you have a problem after that, please post the log in an issue and I can 
+try to fix it but it may be difficult without your particular hardware to 
+test with.
+
+In particular, if you are experiencing `ssh: handshake failed:` first try
+using the `--insecurecipher` flag. If this works, you should upgrade your
+NMC to a newer firmware which includes secure ciphers. You should NOT automate
+your environment using this flag as SSH over these ciphers is broken and
+exploitable. If this also does not work, please run `ssh -vv myups.example.com`
+and include the `peer server KEXINIT proposal` in your issue. For example:
 
 ```
 debug2: peer server KEXINIT proposal
@@ -59,16 +135,18 @@ content.
 
 e.g. `./apc-p15-tool create --keyfile ./apckey.pem --certfile ./apccert.pem`
 
-The command outputs ./apctool.p15 by default. This file can be 
-directly loaded on to an APC NMC2 (Network Management Card 2).
+The command creates and outputs ./apctool.p15 and ./apctool.key.p15 by
+default. These files are equivelant to the key and final p15 files
+generated by APC's proprietary tool.
 
 ### Install
 
-Install works similarly to create except it doesn't save the p15 file 
-to disk. It instead uploads the p15 file directly to the specified 
-remote host, via scp.
+Install generates the necessary p15 file(s) but does NOT save them to
+disk. It instead installs the files directly on the NMC. Logic
+automatically deduces if the device is an NMC2 or NMC3 and performs
+the appropriate installation steps.
 
-e.g. `./apc-p15-tool install --keyfile ./apckey.pem --certfile ./apccert.pem --apchost myapc.example.com:22 --username apc --password someSecret --fingerprint 123abc`
+e.g. `./apc-p15-tool install --keyfile ./apckey.pem --certfile ./apccert.pem --hostname myapc.example.com --username apc --password someSecret --fingerprint 123abc`
 
 ## Note About Install Automation
 
@@ -86,12 +164,12 @@ for passing the pem content from another application without having
 to save the pem files to disk.
 
 Putting all of this together, you can combine the install binary with 
-a tool like LeGo CertHub (https://www.legocerthub.com/) to call the 
+a tool like Cert Warden (https://www.certwarden.com/) to call the 
 install binary, with environment variables, to directly upload new 
-certificates as they're issued by LeGo, without having to write a 
+certificates as they're issued by Cert Warden, without having to write a 
 separate script.
 
-![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
 
diff --git a/build.ps1 b/build.ps1
index 3fee1f4..0c8774b 100644
--- a/build.ps1
+++ b/build.ps1
@@ -11,7 +11,7 @@ go build -o $outDir/apc-p15-tool-amd64.exe ./cmd/tool
 $env:GOARCH = "amd64"
 $env:GOOS = "windows"
 $env:CGO_ENABLED = 0
-go build -o $outDir/apc-p15-install-amd64.exe ./cmd/tool
+go build -o $outDir/apc-p15-install-amd64.exe ./cmd/install_only
 
 # Linux x64
 $env:GOARCH = "amd64"
@@ -34,3 +34,25 @@ $env:GOARCH = "arm64"
 $env:GOOS = "linux"
 $env:CGO_ENABLED = 0
 go build -o $outDir/apc-p15-install-arm64 ./cmd/install_only
+
+# Darwin (MacOS) amd64
+$env:GOARCH = "amd64"
+$env:GOOS = "darwin"
+$env:CGO_ENABLED = 0
+go build -o $outDir/apc-p15-tool-darwin-amd64 ./cmd/tool
+
+$env:GOARCH = "amd64"
+$env:GOOS = "darwin"
+$env:CGO_ENABLED = 0
+go build -o $outDir/apc-p15-install-darwin-amd64 ./cmd/install_only
+
+# Darwin (MacOS) arm64
+$env:GOARCH = "arm64"
+$env:GOOS = "darwin"
+$env:CGO_ENABLED = 0
+go build -o $outDir/apc-p15-tool-darwin-arm64 ./cmd/tool
+
+$env:GOARCH = "arm64"
+$env:GOOS = "darwin"
+$env:CGO_ENABLED = 0
+go build -o $outDir/apc-p15-install-darwin-arm64 ./cmd/install_only
diff --git a/go.mod b/go.mod
index 5d6ca7f..76eb3ca 100644
--- a/go.mod
+++ b/go.mod
@@ -1,19 +1,21 @@
 module apc-p15-tool
 
-go 1.22.1
+go 1.24.2
 
 require (
 	github.com/peterbourgon/ff/v4 v4.0.0-alpha.4
-	github.com/sigurn/crc16 v0.0.0-20211026045750-20ab5afb07e3
-	golang.org/x/crypto v0.18.0
+	github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1
+	golang.org/x/crypto v0.37.0
 )
 
-require golang.org/x/sys v0.16.0 // indirect
+require golang.org/x/sys v0.32.0 // indirect
 
 replace apc-p15-tool/cmd/install_only => /cmd/install_only
 
 replace apc-p15-tool/cmd/tool => /cmd/tool
 
+replace apc-p15-tool/pkg/apcssh => /pkg/apcssh
+
 replace apc-p15-tool/pkg/app => /pkg/app
 
 replace apc-p15-tool/pkg/pkcs15 => /pkg/pkcs15
diff --git a/go.sum b/go.sum
index 96676f0..0b94b0b 100644
--- a/go.sum
+++ b/go.sum
@@ -2,13 +2,13 @@ github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N
 github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
 github.com/peterbourgon/ff/v4 v4.0.0-alpha.4 h1:aiqS8aBlF9PsAKeMddMSfbwp3smONCn3UO8QfUg0Z7Y=
 github.com/peterbourgon/ff/v4 v4.0.0-alpha.4/go.mod h1:H/13DK46DKXy7EaIxPhk2Y0EC8aubKm35nBjBe8AAGc=
-github.com/sigurn/crc16 v0.0.0-20211026045750-20ab5afb07e3 h1:aQKxg3+2p+IFXXg97McgDGT5zcMrQoi0EICZs8Pgchs=
-github.com/sigurn/crc16 v0.0.0-20211026045750-20ab5afb07e3/go.mod h1:9/etS5gpQq9BJsJMWg1wpLbfuSnkm8dPF6FdW2JXVhA=
-golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
-golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
-golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
-golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE=
-golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
+github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1 h1:NVK+OqnavpyFmUiKfUMHrpvbCi2VFoWTrcpI7aDaJ2I=
+github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1/go.mod h1:9/etS5gpQq9BJsJMWg1wpLbfuSnkm8dPF6FdW2JXVhA=
+golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
+golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
+golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
+golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
+golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
 gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
diff --git a/img/apc-p15-tool.png b/img/apc-p15-tool.png
index 807fb84..c537585 100644
Binary files a/img/apc-p15-tool.png and b/img/apc-p15-tool.png differ
diff --git a/pkg/apcssh/client.go b/pkg/apcssh/client.go
new file mode 100644
index 0000000..4e7f1d3
--- /dev/null
+++ b/pkg/apcssh/client.go
@@ -0,0 +1,128 @@
+package apcssh
+
+import (
+	"crypto/sha256"
+	"encoding/base64"
+	"encoding/hex"
+	"fmt"
+	"net"
+	"runtime"
+	"strings"
+	"time"
+
+	"golang.org/x/crypto/ssh"
+)
+
+const (
+	apcSSHVer = 1
+
+	sshTimeout = 90 * time.Second
+)
+
+// APC UPS won't except Go's SSH "Run()" command as the format isn't quite
+// the same. Therefore, write a custom implementation instead of relying on
+// something like github.com/bramvdbogaerde/go-scp
+
+type Config struct {
+	Hostname          string
+	Username          string
+	Password          string
+	ServerFingerprint string
+	InsecureCipher    bool
+}
+
+// Client is an APC UPS SSH client
+type Client struct {
+	hostname string
+	sshCfg   *ssh.ClientConfig
+}
+
+// New creates a new SSH Client for the APC UPS.
+func New(cfg *Config) (*Client, error) {
+	// make host key callback
+	hk := func(_hostname string, _remote net.Addr, key ssh.PublicKey) error {
+		// calculate server's key's SHA256
+		hasher := sha256.New()
+		_, err := hasher.Write(key.Marshal())
+		if err != nil {
+			return err
+		}
+		actualHash := hasher.Sum(nil)
+
+		// log fingerprint for debugging
+		actualHashB64 := base64.RawStdEncoding.EncodeToString(actualHash)
+		actualHashHex := hex.EncodeToString(actualHash)
+
+		// check for fingerprint match (b64 or hex)
+		if actualHashB64 != cfg.ServerFingerprint && actualHashHex != cfg.ServerFingerprint {
+			return fmt.Errorf("apcssh: server returned wrong fingerprint (b64: %s ; hex: %s)", actualHashB64, actualHashHex)
+		}
+
+		return nil
+	}
+
+	// kex algos
+	// see defaults: https://cs.opensource.google/go/x/crypto/+/refs/tags/v0.18.0:ssh/common.go;l=62
+	kexAlgos := []string{
+		"curve25519-sha256", "curve25519-sha256@libssh.org",
+		"ecdh-sha2-nistp256", "ecdh-sha2-nistp384", "ecdh-sha2-nistp521",
+		"diffie-hellman-group14-sha256", "diffie-hellman-group14-sha1",
+	}
+	// extra for some apc ups
+	kexAlgos = append(kexAlgos, "diffie-hellman-group-exchange-sha256")
+
+	// ciphers
+	// see defaults: https://cs.opensource.google/go/x/crypto/+/master:ssh/common.go;l=37
+	ciphers := []string{
+		"aes128-gcm@openssh.com", "aes256-gcm@openssh.com",
+		"chacha20-poly1305@openssh.com",
+		"aes128-ctr", "aes192-ctr", "aes256-ctr",
+	}
+
+	// insecure cipher options?
+	if cfg.InsecureCipher {
+		ciphers = append(ciphers, "aes128-cbc", "3des-cbc")
+	}
+
+	// install file on UPS
+	// ssh config
+	config := &ssh.ClientConfig{
+		User: cfg.Username,
+		Auth: []ssh.AuthMethod{
+			ssh.Password(cfg.Password),
+		},
+		// APC seems to require `Client Version` string to start with "SSH-2" and must be at least
+		// 13 characters long
+		// working examples from other clients:
+		// ClientVersion: "SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.6",
+		// ClientVersion: "SSH-2.0-PuTTY_Release_0.80",
+		ClientVersion: fmt.Sprintf("SSH-2.0-apcssh_v%d %s-%s", apcSSHVer, runtime.GOOS, runtime.GOARCH),
+		Config: ssh.Config{
+			KeyExchanges: kexAlgos,
+			Ciphers:      ciphers,
+		},
+		HostKeyCallback: hk,
+
+		// reasonable timeout for file copy
+		Timeout: sshTimeout,
+	}
+
+	// if hostname missing a port, add default
+	if !strings.Contains(cfg.Hostname, ":") {
+		cfg.Hostname = cfg.Hostname + ":22"
+	}
+
+	// connect to ups over SSH (to verify everything works)
+	sshClient, err := ssh.Dial("tcp", cfg.Hostname, config)
+	if err != nil {
+		return nil, err
+	}
+	_ = sshClient.Close()
+
+	// return Client (note: new ssh Dial will be done for each action as the UPS
+	// seems to not do well with more than one Session per Dial)
+	return &Client{
+		hostname: cfg.Hostname,
+		sshCfg:   config,
+	}, nil
+}
diff --git a/pkg/apcssh/cmd_gettime.go b/pkg/apcssh/cmd_gettime.go
new file mode 100644
index 0000000..139b0ba
--- /dev/null
+++ b/pkg/apcssh/cmd_gettime.go
@@ -0,0 +1,62 @@
+package apcssh
+
+import (
+	"fmt"
+	"regexp"
+	"strings"
+	"time"
+)
+
+// GetTime sends the APC `system` command and then attempts to parse the
+// response to determine the UPS current date/time.
+func (cli *Client) GetTime() (time.Time, error) {
+	result, err := cli.cmd("date")
+	if err != nil {
+		return time.Time{}, fmt.Errorf("apcssh: failed to get time (%s)", err)
+	} else if !strings.EqualFold(result.code, "e000") {
+		return time.Time{}, fmt.Errorf("apcssh: failed to get time (%s: %s)", result.code, result.codeText)
+	}
+
+	// capture each portion of the date information
+	regex := regexp.MustCompile(`Date:\s*(\S*)\s*[\r\n]Time:\s*(\S*)\s*[\r\n]Format:\s*(\S*)\s*[\r\n]Time Zone:\s*(\S*)\s*[\r\n]?`)
+	datePieces := regex.FindStringSubmatch(result.resultText)
+	if len(datePieces) != 5 {
+		return time.Time{}, fmt.Errorf("apcssh: failed to get time (length of datetime value pieces was %d (expected: 5))", len(datePieces))
+	}
+	dateVal := datePieces[1]
+	timeVal := datePieces[2]
+	formatUPSVal := datePieces[3]
+	timeZoneVal := datePieces[4]
+
+	// GMT time requires + prefix
+	// APC UPS fails to use the required +, so add it
+	if timeZoneVal == "00:00" {
+		timeZoneVal = "+" + timeZoneVal
+	}
+
+	// known APC UPS format strings
+	dateFormatVal := ""
+	switch formatUPSVal {
+	case "mm/dd/yyyy":
+		dateFormatVal = "01/02/2006"
+	case "dd.mm.yyyy":
+		dateFormatVal = "02.01.2006"
+	case "mmm-dd-yy":
+		dateFormatVal = "Jan-02-06"
+	case "dd-mmm-yy":
+		dateFormatVal = "02-Jan-06"
+	case "yyyy-mm-dd":
+		dateFormatVal = "2006-01-02"
+
+	default:
+		return time.Time{}, fmt.Errorf("apcssh: failed to get time (ups returned unknown format string (%s)", formatUPSVal)
+	}
+
+	// convert to time.Time
+	t, err := time.Parse(dateFormatVal+" 15:04:05 -07:00", dateVal+" "+timeVal+" "+timeZoneVal)
+	if err != nil {
+		return time.Time{}, fmt.Errorf("apcssh: failed to get time (time parse failed: %s)", err)
+	}
+
+	return t, nil
+}
diff --git a/pkg/apcssh/cmd_restartwebui.go b/pkg/apcssh/cmd_restartwebui.go
new file mode 100644
index 0000000..5196212
--- /dev/null
+++ b/pkg/apcssh/cmd_restartwebui.go
@@ -0,0 +1,22 @@
+package apcssh
+
+import (
+	"fmt"
+	"strings"
+)
+
+// RestartWebUI sends the APC command to restart the web ui
+// WARNING: Sending a command directly after this one will cause issues.
+// This command will cause SSH to also restart after a slight delay, therefore
+// any command right after this will start to run but then get stuck / fail
+// somewhere in the middle.
+func (cli *Client) RestartWebUI() error {
+	result, err := cli.cmd("reboot -Y")
+	if err != nil {
+		return fmt.Errorf("apcssh: failed to restart web ui (%w)", err)
+	} else if !strings.EqualFold(result.code, "e000") {
+		return fmt.Errorf("apcssh: failed to restart web ui (%s: %s)", result.code, result.codeText)
+	}
+
+	return nil
+}
diff --git a/pkg/apcssh/scp.go b/pkg/apcssh/scp.go
new file mode 100644
index 0000000..a37d154
--- /dev/null
+++ b/pkg/apcssh/scp.go
@@ -0,0 +1,129 @@
+package apcssh
+
+import (
+	"bufio"
+	"bytes"
+	"errors"
+	"fmt"
+	"io"
+	"io/fs"
+	"path"
+
+	"golang.org/x/crypto/ssh"
+)
+
+// UploadSCP uploads a file to the destination specified (e.g., "/ssl/file.key")
+// containing the file content specified. An existing file at the destination
+// will be overwritten without warning.
+func (cli *Client) UploadSCP(destination string, fileContent []byte, filePermissions fs.FileMode) error {
+	// connect
+	sshClient, err := ssh.Dial("tcp", cli.hostname, cli.sshCfg)
+	if err != nil {
+		return fmt.Errorf("apcssh: scp: failed to dial client (%w)", err)
+	}
+	defer sshClient.Close()
+
+	// make session to use for SCP
+	session, err := sshClient.NewSession()
+	if err != nil {
+		return fmt.Errorf("apcssh: scp: failed to create session (%w)", err)
+	}
+	defer session.Close()
+
+	// attach pipes
+	out, err := session.StdoutPipe()
+	if err != nil {
+		return err
+	}
+	w, err := session.StdinPipe()
+	if err != nil {
+		return err
+	}
+	defer w.Close()
+
+	// send execute cmd --
+	// build cmd to send as request
+	// Go implementation sends additional 0x22 bytes when using Run() (as
+	// compared to putty's scp tool). these additional bytes seem to cause the
+	// apc ups to fail execution of the command
+	payload := []byte(fmt.Sprintf("scp -q -t %s", destination))
+	payloadLen := uint8(len(payload))
+	payload = append([]byte{0, 0, 0, payloadLen}, payload...)
+
+	ok, err := session.SendRequest("exec", true, payload)
+	if err != nil {
+		return fmt.Errorf("apcssh: scp: failed to execute scp cmd (%w)", err)
+	}
+	if !ok {
+		return errors.New("apcssh: scp: execute scp cmd not ok")
+	}
+
+	// check remote response
+	// Note: File upload may not work if the client doesn't actually read from
+	// the remote output.
+	err = scpCheckResponse(out)
+	if err != nil {
+		return fmt.Errorf("apcssh: scp: failed to send scp cmd (bad remote response 1) (%w)", err)
+	}
+
+	// just file name (without path)
+	filename := path.Base(destination)
+
+	// send file header
+	_, err = fmt.Fprintln(w, "C"+fmt.Sprintf("%04o", filePermissions.Perm()), len(fileContent), filename)
+	if err != nil {
+		return fmt.Errorf("apcssh: scp: failed to send file info (%w)", err)
+	}
+
+	err = scpCheckResponse(out)
+	if err != nil {
+		return fmt.Errorf("apcssh: scp: failed to send file info (bad remote response 2) (%w)", err)
+	}
+
+	// send actual file
+	_, err = io.Copy(w, bytes.NewReader(fileContent))
+	if err != nil {
+		return fmt.Errorf("apcssh: scp: failed to send file(%w)", err)
+	}
+
+	// send file end
+	_, err = fmt.Fprint(w, "\x00")
+	if err != nil {
+		return fmt.Errorf("apcssh: scp: failed to send final 00 byte (%w)", err)
+	}
+
+	err = scpCheckResponse(out)
+	if err != nil {
+		return fmt.Errorf("apcssh: scp: failed to send file (bad remote response 3) (%w)", err)
+	}
+
+	// done
+	return nil
+}
+
+// scpCheckResponse reads the output from the remote and returns an error
+// if the remote output was not 0
+func scpCheckResponse(remoteOutPipe io.Reader) error {
+	buffer := make([]uint8, 1)
+	_, err := remoteOutPipe.Read(buffer)
+	if err != nil {
+		return fmt.Errorf("apcssh: failed to read output buffer (%w)", err)
+	}
+
+	responseType := buffer[0]
+	message := ""
+	if responseType > 0 {
+		bufferedReader := bufio.NewReader(remoteOutPipe)
+		message, err = bufferedReader.ReadString('\n')
+		if err != nil {
+			return fmt.Errorf("apcssh: failed to read output buffer (%w)", err)
+		}
+	}
+
+	// if not 0 (aka OK)
+	if responseType != 0 {
+		return fmt.Errorf("apcssh: remote returned error (%d: %s)", responseType, message)
+	}
+
+	return nil
+}
diff --git a/pkg/apcssh/shell.go b/pkg/apcssh/shell.go
new file mode 100644
index 0000000..bb05639
--- /dev/null
+++ b/pkg/apcssh/shell.go
@@ -0,0 +1,139 @@
+package apcssh
+
+import (
+	"bufio"
+	"errors"
+	"fmt"
+	"strings"
+	"time"
+
+	"golang.org/x/crypto/ssh"
+)
+
+// Abort shell connection if UPS doesn't send a recognizable response within
+// the specified timeouts; Cmd timeout is very long as it is unlikely to be
+// needed but still exists to avoid an indefinite hang in the unlikely event
+// something does go wrong at that part of the app
+const (
+	shellTimeoutLogin = 20 * time.Second
+	shellTimeoutCmd   = 5 * time.Minute
+)
+
+// upsCmdResult is a structure that holds all of a shell commands results
+type upsCmdResult struct {
+	command    string
+	code       string
+	codeText   string
+	resultText string
+}
+
+// cmd creates an interactive shell and executes the specified command
+func (cli *Client) cmd(command string) (*upsCmdResult, error) {
+	// connect
+	sshClient, err := ssh.Dial("tcp", cli.hostname, cli.sshCfg)
+	if err != nil {
+		return nil, fmt.Errorf("failed to dial client (%w)", err)
+	}
+	defer sshClient.Close()
+
+	session, err := sshClient.NewSession()
+	if err != nil {
+		return nil, fmt.Errorf("failed to create session (%w)", err)
+	}
+	defer session.Close()
+
+	// pipes to send shell command to; and to receive repsonse
+	sshInput, err := session.StdinPipe()
+	if err != nil {
+		return nil, fmt.Errorf("failed to make shell input pipe (%w)", err)
+	}
+	sshOutput, err := session.StdoutPipe()
+	if err != nil {
+		return nil, fmt.Errorf("failed to make shell output pipe (%w)", err)
+	}
+
+	// make scanner to read shell output continuously
+	scanner := bufio.NewScanner(sshOutput)
+	scanner.Split(scanAPCShell)
+
+	// start interactive shell
+	if err := session.Shell(); err != nil {
+		return nil, fmt.Errorf("failed to start shell (%w)", err)
+	}
+
+	// use a timer to close the session early in case Scan() hangs (which can
+	// happen if the UPS provides output this app does not understand)
+	cancelAbort := make(chan struct{})
+	defer close(cancelAbort)
+	go func() {
+		select {
+		case <-time.After(shellTimeoutLogin):
+			_ = session.Close()
+
+		case <-cancelAbort:
+			// aborted cancel (i.e., succesful Scan())
+		}
+	}()
+
+	// check shell response after connect
+	scannedOk := scanner.Scan()
+	// if failed to scan (e.g., timer closed the session after timeout)
+	if !scannedOk {
+		return nil, errors.New("shell did not return parsable login response")
+	}
+	// success; cancel abort timer
+	cancelAbort <- struct{}{}
+	// discard the initial shell response (login message(s) / initial shell prompt)
+	_ = scanner.Bytes()
+
+	// send command
+	_, err = fmt.Fprint(sshInput, command+"\n")
+	if err != nil {
+		return nil, fmt.Errorf("failed to send shell command (%w)", err)
+	}
+
+	// use a timer to close the session early in case Scan() hangs (which can
+	// happen if the UPS provides output this app does not understand);
+	// since initial login message Scan() was okay, it is relatively unlikely this
+	// will hang
+	go func() {
+		select {
+		case <-time.After(shellTimeoutCmd):
+			_ = session.Close()
+
+		case <-cancelAbort:
+			// aborted cancel (i.e., succesful Scan())
+		}
+	}()
+
+	// check shell response to command
+	scannedOk = scanner.Scan()
+	// if failed to scan (e.g., timer closed the session after timeout)
+	if !scannedOk {
+		return nil, fmt.Errorf("shell did not return parsable response to cmd '%s'", command)
+	}
+	// success; cancel abort timer
+	cancelAbort <- struct{}{}
+
+	// parse the UPS response into result struct and return
+	upsRawResponse := string(scanner.Bytes())
+	result := &upsCmdResult{}
+
+	cmdIndx := strings.Index(upsRawResponse, "\n")
+	result.command = upsRawResponse[:cmdIndx-1]
+	upsRawResponse = upsRawResponse[cmdIndx+1:]
+
+	codeIndx := strings.Index(upsRawResponse, ": ")
+	result.code = upsRawResponse[:codeIndx]
+	upsRawResponse = upsRawResponse[codeIndx+2:]
+
+	codeTxtIndx := strings.Index(upsRawResponse, "\n")
+	result.codeText = upsRawResponse[:codeTxtIndx-1]
+
+	// avoid out of bounds if no result text
+	if codeTxtIndx+1 <= len(upsRawResponse)-2 {
+		result.resultText = upsRawResponse[codeTxtIndx+1 : len(upsRawResponse)-2]
+	}
+
+	return result, nil
+}
diff --git a/pkg/apcssh/shell_helpers.go b/pkg/apcssh/shell_helpers.go
new file mode 100644
index 0000000..02c5e81
--- /dev/null
+++ b/pkg/apcssh/shell_helpers.go
@@ -0,0 +1,38 @@
+package apcssh
+
+import (
+	"io"
+	"regexp"
+)
+
+// scanAPCShell is a SplitFunc to capture shell output after each interactive
+// shell command is run
+func scanAPCShell(data []byte, atEOF bool) (advance int, token []byte, err error) {
+	// EOF is not an expected response and should error (e.g., when the output pipe
+	// gets closed by timeout)
+	if atEOF {
+		return len(data), dropCR(data), io.ErrUnexpectedEOF
+	} else if len(data) == 0 {
+		// no data to process, request more data
+		return 0, nil, nil
+	}
+
+	// regex for shell prompt (e.g., `apc@apc>`, `apc>`, `some@dev>`, `other123>`, etc.)
+	re := regexp.MustCompile(`(\r\n|\r|\n)([A-Za-z0-9.]+@?)?[A-Za-z0-9.]+>`)
+	// find match for prompt
+	if index := re.FindStringIndex(string(data)); index != nil {
+		// advance starts after the prompt; token is everything before the prompt
+		return index[1], dropCR(data[0:index[0]]), nil
+	}
+
+	// no match, request more data
+	return 0, nil, nil
+}
+
+// dropCR drops a terminal \r from the data.
+func dropCR(data []byte) []byte {
+	if len(data) > 0 && data[len(data)-1] == '\r' {
+		return data[0 : len(data)-1]
+	}
+	return data
+}
diff --git a/pkg/apcssh/ssl.go b/pkg/apcssh/ssl.go
new file mode 100644
index 0000000..18bff70
--- /dev/null
+++ b/pkg/apcssh/ssl.go
@@ -0,0 +1,86 @@
+package apcssh
+
+import (
+	"errors"
+	"fmt"
+	"strings"
+)
+
+var errSSLMissingData = errors.New("apcssh: ssl cert install: cant install nil data (unsupported key/nmc version/nmc firmware combo?)")
+
+// InstallSSLCert installs the specified p15 key and p15 cert files on the
+// UPS. It has logic to deduce if the NMC is a newer version (e.g., NMC3 with
+// newer firmware) and acts accordingly.
+func (cli *Client) InstallSSLCert(keyP15 []byte, certPem []byte, keyCertP15 []byte) error {
+	// run `ssl` command to check if it exists
+	result, err := cli.cmd("ssl")
+	if err != nil {
+		return fmt.Errorf("apcssh: ssl cert install: failed to test ssl cmd (%w)", err)
+	}
+	// E101 is the code for "Command Not Found"
+	supportsSSLCmd := !strings.EqualFold(result.code, "e101")
+
+	// if SSL is supported, use that method
+	if supportsSSLCmd {
+		return cli.installSSLCertModern(keyP15, certPem)
+	}
+
+	// fallback to legacy
+	return cli.installSSLCertLegacy(keyCertP15)
+}
+
+// installSSLCertModern installs the SSL key and certificate using the UPS built-in
+// command `ssl`. This command is not present on older devices (e.g., NMC2) or firmwares.
+func (cli *Client) installSSLCertModern(keyP15 []byte, certPem []byte) error {
+	// fail if required data isn't present
+	if keyP15 == nil || len(keyP15) <= 0 || certPem == nil || len(certPem) <= 0 {
+		return errSSLMissingData
+	}
+
+	// upload the key P15 file
+	err := cli.UploadSCP("/ssl/nmc.key", keyP15, 0600)
+	if err != nil {
+		return fmt.Errorf("apcssh: ssl cert install: failed to send nmc.key file to ups over scp (%w)", err)
+	}
+
+	// upload the cert PEM file
+	err = cli.UploadSCP("/ssl/nmc.crt", certPem, 0666)
+	if err != nil {
+		return fmt.Errorf("apcssh: ssl cert install: failed to send nmc.key file to ups over scp (%w)", err)
+	}
+
+	// run `ssl` install commands
+	result, err := cli.cmd("ssl key -i /ssl/nmc.key")
+	if err != nil {
+		return fmt.Errorf("apcssh: ssl cert install: failed to send ssl key install cmd (%w)", err)
+	} else if !strings.EqualFold(result.code, "e000") {
+		return fmt.Errorf("apcssh: ssl cert install: ssl key install cmd returned error code (%s: %s)", result.code, result.codeText)
+	}
+
+	result, err = cli.cmd("ssl cert -i /ssl/nmc.crt")
+	if err != nil {
+		return fmt.Errorf("apcssh: ssl cert install: failed to send ssl cert install cmd (%w)", err)
+	} else if !strings.EqualFold(result.code, "e000") {
+		return fmt.Errorf("apcssh: ssl cert install: ssl cert install cmd returned error code (%s: %s)", result.code, result.codeText)
+	}
+
+	return nil
+}
+
+// installSSLCertLegacy installs the SSL key and certificate by directly uploading
+// them to a .p15 file on the UPS. This is used for older devices (e.g., NMC2) and
+// firmwares that do not support the `ssl` command.
+func (cli *Client) installSSLCertLegacy(keyCertP15 []byte) error {
+	// fail if required data isn't present
+	if keyCertP15 == nil || len(keyCertP15) <= 0 {
+		return errSSLMissingData
+	}
+
+	// upload/install keyCert P15 file
+	err := cli.UploadSCP("/ssl/defaultcert.p15", keyCertP15, 0600)
+	if err != nil {
+		return fmt.Errorf("apcssh: ssl cert install: failed to send defaultcert.p15 file to ups over scp (%w)", err)
+	}
+
+	return nil
+}
diff --git a/pkg/app/app.go b/pkg/app/app.go
index 4db20fa..052a00e 100644
--- a/pkg/app/app.go
+++ b/pkg/app/app.go
@@ -12,7 +12,7 @@ import (
 )
 
 const (
-	appVersion = "0.4.1"
+	appVersion = "1.2.2"
 )
 
 // struct for receivers to use common app pieces
diff --git a/pkg/app/cmd_create.go b/pkg/app/cmd_create.go
index 51cf1cc..8791a81 100644
--- a/pkg/app/cmd_create.go
+++ b/pkg/app/cmd_create.go
@@ -2,18 +2,19 @@ package app
 
 import (
 	"context"
+	"encoding/base64"
 	"fmt"
 	"os"
 )
 
-const createDefaultOutFilePath = "apctool.p15"
+const (
+	createDefaultOutFilePath    = "apctool.p15"
+	createDefaultOutKeyFilePath = "apctool.key.p15"
+)
 
 // cmdCreate is the app's command to create an apc p15 file from key and cert
 // pem files
 func (app *app) cmdCreate(_ context.Context, args []string) error {
-	// done
-	defer app.stdLogger.Println("create: done")
-
 	// extra args == error
 	if len(args) != 0 {
 		return fmt.Errorf("create: failed, %w (%d)", ErrExtraArgs, len(args))
@@ -26,25 +27,67 @@ func (app *app) cmdCreate(_ context.Context, args []string) error {
 
 	// validation done
 
-	// make p15 file
-	apcFile, err := app.pemToAPCP15(keyPem, certPem, "create")
+	// make p15 files
+	keyFile, apcKeyCertFile, err := app.pemToAPCP15(keyPem, certPem, "create")
 	if err != nil {
 		return err
 	}
 
 	// determine file name (should already be done by flag parsing, but avoid nil just in case)
-	fileName := createDefaultOutFilePath
+	keyCertFileName := createDefaultOutFilePath
 	if app.config.create.outFilePath != nil && *app.config.create.outFilePath != "" {
-		fileName = *app.config.create.outFilePath
+		keyCertFileName = *app.config.create.outFilePath
 	}
 
-	// write file
-	err = os.WriteFile(fileName, apcFile, 0777)
+	keyFileName := createDefaultOutFilePath
+	if app.config.create.outKeyFilePath != nil && *app.config.create.outKeyFilePath != "" {
+		keyFileName = *app.config.create.outKeyFilePath
+	}
+
+	// write file(s)
+	err = os.WriteFile(keyFileName, keyFile, 0600)
 	if err != nil {
-		return fmt.Errorf("create: failed to write apc p15 file (%s)", err)
+		return fmt.Errorf("create: failed to write apc p15 key file (%s)", err)
+	}
+	app.stdLogger.Printf("create: apc p15 key file %s written to disk", keyFileName)
+
+	// skip key+cert if it wasn't generated
+	if len(apcKeyCertFile) > 0 {
+		err = os.WriteFile(keyCertFileName, apcKeyCertFile, 0600)
+		if err != nil {
+			return fmt.Errorf("create: failed to write apc p15 key+cert file (%s)", err)
+		}
+		app.stdLogger.Printf("create: apc p15 key+cert file %s written to disk", keyCertFileName)
 	}
 
-	app.stdLogger.Printf("create: apc p15 file %s written to disk", fileName)
+	// if debug, write additional debug files (b64 format to make copy/paste into asn1 decoder
+	// easy to do e.g., https://lapo.it/asn1js)
+	if app.config.debugLogging != nil && *app.config.debugLogging {
+		keyFileNameDebug := keyFileName + ".b64"
+		err = os.WriteFile(keyFileNameDebug, []byte(base64.StdEncoding.EncodeToString(keyFile)), 0600)
+		if err != nil {
+			return fmt.Errorf("create: failed to write apc p15 key file (%s)", err)
+		}
+		app.debugLogger.Printf("create: apc p15 key file %s written to disk", keyFileNameDebug)
+
+		// skip key+cert if it wasn't generated
+		if len(apcKeyCertFile) > 0 {
+			keyCertFileNameDebug := keyCertFileName + ".noheader.b64"
+			err = os.WriteFile(keyCertFileNameDebug, []byte(base64.StdEncoding.EncodeToString(apcKeyCertFile[apcHeaderLen:])), 0600)
+			if err != nil {
+				return fmt.Errorf("create: failed to write apc p15 key+cert file (%s)", err)
+			}
+			app.debugLogger.Printf("create: apc p15 key+cert file %s written to disk", keyCertFileNameDebug)
+
+			keyCertFileNameHeaderDebug := keyCertFileName + ".header.b64"
+			err = os.WriteFile(keyCertFileNameHeaderDebug, []byte(base64.StdEncoding.EncodeToString(apcKeyCertFile[:apcHeaderLen])), 0600)
+			if err != nil {
+				return fmt.Errorf("create: failed to write apc p15 key+cert file (%s)", err)
+			}
+			app.debugLogger.Printf("create: apc p15 key+cert file header %s written to disk", keyCertFileNameHeaderDebug)
+		}
+
+	}
 
 	return nil
 }
diff --git a/pkg/app/cmd_install.go b/pkg/app/cmd_install.go
index 5d826ec..eacda53 100644
--- a/pkg/app/cmd_install.go
+++ b/pkg/app/cmd_install.go
@@ -1,24 +1,22 @@
 package app
 
 import (
+	"apc-p15-tool/pkg/apcssh"
+	"bytes"
 	"context"
-	"crypto/sha256"
-	"encoding/base64"
-	"encoding/hex"
+	"crypto/tls"
+	"encoding/pem"
 	"errors"
 	"fmt"
-	"net"
-	"runtime"
-
-	"golang.org/x/crypto/ssh"
+	"strconv"
+	"time"
 )
 
+const timeLoggingFormat = time.RFC1123Z
+
 // cmdInstall is the app's command to create apc p15 file content from key and cert
 // pem files and upload the p15 to the specified APC UPS
 func (app *app) cmdInstall(cmdCtx context.Context, args []string) error {
-	// done
-	defer app.stdLogger.Println("install: done")
-
 	// extra args == error
 	if len(args) != 0 {
 		return fmt.Errorf("install: failed, %w (%d)", ErrExtraArgs, len(args))
@@ -45,126 +43,64 @@ func (app *app) cmdInstall(cmdCtx context.Context, args []string) error {
 	}
 
 	// host to install on must be specified
-	if app.config.install.hostAndPort == nil || *app.config.install.hostAndPort == "" {
+	if app.config.install.hostname == nil || *app.config.install.hostname == "" ||
+		app.config.install.sshport == nil || *app.config.install.sshport == 0 {
+
 		return errors.New("install: failed, apc host not specified")
 	}
 
 	// validation done
 
 	// make p15 file
-	apcFile, err := app.pemToAPCP15(keyPem, certPem, "install")
+	keyP15, keyCertP15, err := app.pemToAPCP15(keyPem, certPem, "install")
 	if err != nil {
 		return err
 	}
 
-	// make host key callback
-	hk := func(hostname string, remote net.Addr, key ssh.PublicKey) error {
-		// calculate server's key's SHA256
-		hasher := sha256.New()
-		_, err := hasher.Write(key.Marshal())
-		if err != nil {
-			return err
-		}
-		actualHash := hasher.Sum(nil)
-
-		// log fingerprint for debugging
-		actualHashB64 := base64.RawStdEncoding.EncodeToString(actualHash)
-		actualHashHex := hex.EncodeToString(actualHash)
-		app.debugLogger.Printf("ssh: remote server key fingerprint (b64): %s", actualHashB64)
-		app.debugLogger.Printf("ssh: remote server key fingerprint (hex): %s", actualHashHex)
-
-		// allow base64 format
-		if actualHashB64 == *app.config.install.fingerprint {
-			return nil
-		}
-
-		// allow hex format
-		if actualHashHex == *app.config.install.fingerprint {
-			return nil
-		}
-
-		return errors.New("ssh: fingerprint didn't match")
-	}
-
-	// kex algos
-	// see defaults: https://cs.opensource.google/go/x/crypto/+/refs/tags/v0.18.0:ssh/common.go;l=62
-	kexAlgos := []string{
-		"curve25519-sha256", "curve25519-sha256@libssh.org",
-		"ecdh-sha2-nistp256", "ecdh-sha2-nistp384", "ecdh-sha2-nistp521",
-		"diffie-hellman-group14-sha256", "diffie-hellman-group14-sha1",
-	}
-	// extra for some apc ups
-	kexAlgos = append(kexAlgos, "diffie-hellman-group-exchange-sha256")
-
-	// ciphers
-	// see defaults: https://cs.opensource.google/go/x/crypto/+/master:ssh/common.go;l=37
-	ciphers := []string{
-		"aes128-gcm@openssh.com", "aes256-gcm@openssh.com",
-		"chacha20-poly1305@openssh.com",
-		"aes128-ctr", "aes192-ctr", "aes256-ctr",
-	}
-
-	// insecure cipher options?
+	// log warning if insecure cipher
 	if app.config.install.insecureCipher != nil && *app.config.install.insecureCipher {
-		app.stdLogger.Println("WARNING: insecure ciphers are enabled (--insecurecipher). SSH with an insecure cipher is NOT secure and should NOT be used.")
-		ciphers = append(ciphers, "aes128-cbc", "3des-cbc")
+		app.stdLogger.Println("WARNING: install: insecure ciphers are enabled (--insecurecipher). SSH with an insecure cipher is NOT secure and should NOT be used.")
 	}
 
-	// install file on UPS
-	// ssh config
-	config := &ssh.ClientConfig{
-		User: *app.config.install.username,
-		Auth: []ssh.AuthMethod{
-			ssh.Password(*app.config.install.password),
-		},
-		// APC seems to require `Client Version` string to start with "SSH-2" and must be at least
-		// 13 characters long
-		// working examples from other clients:
-		// ClientVersion: "SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.6",
-		// ClientVersion: "SSH-2.0-PuTTY_Release_0.80",
-		ClientVersion: fmt.Sprintf("SSH-2.0-apc-p15-tool_v%s %s-%s", appVersion, runtime.GOOS, runtime.GOARCH),
-		Config: ssh.Config{
-			KeyExchanges: kexAlgos,
-			Ciphers:      ciphers,
-			// MACs:         []string{"hmac-sha2-256"},
-		},
-		// HostKeyAlgorithms: []string{"ssh-rsa"},
-		HostKeyCallback: hk,
-
-		// reasonable timeout for file copy
-		Timeout: sshScpTimeout,
+	// make APC SSH client
+	cfg := &apcssh.Config{
+		Hostname:          *app.config.install.hostname + ":" + strconv.Itoa(*app.config.install.sshport),
+		Username:          *app.config.install.username,
+		Password:          *app.config.install.password,
+		ServerFingerprint: *app.config.install.fingerprint,
+		InsecureCipher:    *app.config.install.insecureCipher,
 	}
 
-	// connect to ups over SSH
-	client, err := ssh.Dial("tcp", *app.config.install.hostAndPort, config)
+	client, err := apcssh.New(cfg)
 	if err != nil {
 		return fmt.Errorf("install: failed to connect to host (%w)", err)
 	}
-	defer client.Close()
+	app.stdLogger.Println("install: connected to ups ssh, installing ssl key and cert...")
 
-	// send file to UPS
-	err = sshScpSendFileToUPS(client, apcFile)
+	// check time - don't fail it time is no good, just do logging here
+	upsT, err := client.GetTime()
 	if err != nil {
-		return fmt.Errorf("install: failed to send p15 file to ups over scp (%w)", err)
+		app.errLogger.Printf("warn: install: failed to fetch UPS time (%s), you should manually verify the time is correct on the UPS", err)
+	} else if upsT.After(time.Now().Add(1*time.Hour)) || upsT.Before(time.Now().Add(-1*time.Hour)) {
+		app.errLogger.Printf("warn: install: UPS clock skew detected (this system's time is %s vs. UPS time %s", time.Now().Format(timeLoggingFormat), upsT.Format(timeLoggingFormat))
+	} else {
+		app.stdLogger.Printf("install: UPS clock appears correct (%s)", upsT.Format(timeLoggingFormat))
+	}
+
+	// install SSL Cert
+	err = client.InstallSSLCert(keyP15, certPem, keyCertP15)
+	if err != nil {
+		return fmt.Errorf("install: %w", err)
 	}
 
 	// installed
-	app.stdLogger.Printf("install: apc p15 file installed on %s", *app.config.install.hostAndPort)
+	app.stdLogger.Printf("install: apc p15 file installed on %s", *app.config.install.hostname)
 
 	// restart UPS webUI
 	if app.config.install.restartWebUI != nil && *app.config.install.restartWebUI {
 		app.stdLogger.Println("install: sending restart command")
 
-		// connect to ups over SSH
-		// opening a second session doesn't seem to work with my NMC2 for some reason, so make
-		// a new connection instead
-		client, err = ssh.Dial("tcp", *app.config.install.hostAndPort, config)
-		if err != nil {
-			return fmt.Errorf("install: failed to reconnect to host to send webui restart command (%w)", err)
-		}
-		defer client.Close()
-
-		err = sshResetUPSWebUI(client)
+		err = client.RestartWebUI()
 		if err != nil {
 			return fmt.Errorf("install: failed to send webui restart command (%w)", err)
 		}
@@ -172,5 +108,48 @@ func (app *app) cmdInstall(cmdCtx context.Context, args []string) error {
 		app.stdLogger.Println("install: sent webui restart command")
 	}
 
+	// check the new certificate is installed
+	if app.config.install.skipVerify != nil && !*app.config.install.skipVerify &&
+		app.config.install.webUISSLPort != nil && *app.config.install.webUISSLPort != 0 {
+
+		app.stdLogger.Println("install: attempting to verify certificate install...")
+
+		// sleep for UPS to finish anything it might be doing
+		time.Sleep(5 * time.Second)
+
+		// if UPS web UI was restarted, sleep longer
+		if app.config.install.restartWebUI != nil && *app.config.install.restartWebUI {
+			app.stdLogger.Println("install: waiting for ups webui restart...")
+			time.Sleep(25 * time.Second)
+		}
+
+		// connect to the web UI to get the current certificate
+		conf := &tls.Config{
+			InsecureSkipVerify: true,
+		}
+		conn, err := tls.Dial("tcp", *app.config.install.hostname+":"+strconv.Itoa(*app.config.install.webUISSLPort), conf)
+		if err != nil {
+			return fmt.Errorf("install: failed to dial webui for verification (%s)", err)
+		}
+		defer conn.Close()
+
+		// get top cert
+		leafCert := conn.ConnectionState().PeerCertificates[0]
+		if leafCert == nil {
+			return fmt.Errorf("install: failed to get web ui leaf cert for verification (%s)", err)
+		}
+
+		// convert pem to DER for comparison
+		pemBlock, _ := pem.Decode(certPem)
+
+		// verify cert is the correct one
+		certVerified := bytes.Equal(leafCert.Raw, pemBlock.Bytes)
+		if !certVerified {
+			return errors.New("install: web ui leaf cert does not match new cert")
+		}
+
+		app.stdLogger.Println("install: ups web ui cert verified")
+	}
+
 	return nil
 }
diff --git a/pkg/app/config.go b/pkg/app/config.go
index 893bb9c..7edf216 100644
--- a/pkg/app/config.go
+++ b/pkg/app/config.go
@@ -28,15 +28,19 @@ type config struct {
 	debugLogging *bool
 	create       struct {
 		keyCertPemCfg
-		outFilePath *string
+		outFilePath    *string
+		outKeyFilePath *string
 	}
 	install struct {
 		keyCertPemCfg
-		hostAndPort    *string
+		hostname       *string
+		sshport        *int
 		fingerprint    *string
 		username       *string
 		password       *string
 		restartWebUI   *bool
+		webUISSLPort   *int
+		skipVerify     *bool
 		insecureCipher *bool
 	}
 }
@@ -56,7 +60,7 @@ func (app *app) getConfig(args []string) error {
 	// apc-p15-tool -- root command
 	rootFlags := ff.NewFlagSet("apc-p15-tool")
 
-	cfg.debugLogging = rootFlags.BoolLong("debug", "set this flag to enable additional debug logging messages")
+	cfg.debugLogging = rootFlags.BoolLong("debug", "set this flag to enable additional debug logging messages and files")
 
 	rootCmd := &ff.Command{
 		Name:  "apc-p15-tool",
@@ -67,11 +71,12 @@ func (app *app) getConfig(args []string) error {
 	// create -- subcommand
 	createFlags := ff.NewFlagSet("create").SetParent(rootFlags)
 
-	cfg.create.keyPemFilePath = createFlags.StringLong("keyfile", "", "path and filename of the rsa-1024 or rsa-2048 key in pem format")
+	cfg.create.keyPemFilePath = createFlags.StringLong("keyfile", "", "path and filename of the key in pem format")
 	cfg.create.certPemFilePath = createFlags.StringLong("certfile", "", "path and filename of the certificate in pem format")
-	cfg.create.keyPem = createFlags.StringLong("keypem", "", "string of the rsa-1024 or rsa-2048 key in pem format")
+	cfg.create.keyPem = createFlags.StringLong("keypem", "", "string of the key in pem format")
 	cfg.create.certPem = createFlags.StringLong("certpem", "", "string of the certificate in pem format")
-	cfg.create.outFilePath = createFlags.StringLong("outfile", createDefaultOutFilePath, "path and filename to write the p15 file to")
+	cfg.create.outFilePath = createFlags.StringLong("outfile", createDefaultOutFilePath, "path and filename to write the key+cert p15 file to")
+	cfg.create.outKeyFilePath = createFlags.StringLong("outkeyfile", createDefaultOutKeyFilePath, "path and filename to write the key p15 file to")
 
 	createCmd := &ff.Command{
 		Name:      "create",
@@ -86,20 +91,23 @@ func (app *app) getConfig(args []string) error {
 	// install -- subcommand
 	installFlags := ff.NewFlagSet("install").SetParent(rootFlags)
 
-	cfg.install.keyPemFilePath = installFlags.StringLong("keyfile", "", "path and filename of the rsa-1024 or rsa-2048 key in pem format")
+	cfg.install.keyPemFilePath = installFlags.StringLong("keyfile", "", "path and filename of the key in pem format")
 	cfg.install.certPemFilePath = installFlags.StringLong("certfile", "", "path and filename of the certificate in pem format")
-	cfg.install.keyPem = installFlags.StringLong("keypem", "", "string of the rsa-1024 or rsa-2048 key in pem format")
+	cfg.install.keyPem = installFlags.StringLong("keypem", "", "string of the key in pem format")
 	cfg.install.certPem = installFlags.StringLong("certpem", "", "string of the certificate in pem format")
-	cfg.install.hostAndPort = installFlags.StringLong("apchost", "", "hostname:port of the apc ups to install the certificate on")
+	cfg.install.hostname = installFlags.StringLong("hostname", "", "hostname of the apc ups to install the certificate on")
+	cfg.install.sshport = installFlags.IntLong("sshport", 22, "apc ups ssh port number")
 	cfg.install.fingerprint = installFlags.StringLong("fingerprint", "", "the SHA256 fingerprint value of the ups' ssh server")
 	cfg.install.username = installFlags.StringLong("username", "", "username to login to the apc ups")
 	cfg.install.password = installFlags.StringLong("password", "", "password to login to the apc ups")
 	cfg.install.restartWebUI = installFlags.BoolLong("restartwebui", "some devices may need a webui restart to begin using the new cert, enabling this option sends the restart command after the p15 is installed")
+	cfg.install.webUISSLPort = installFlags.IntLong("sslport", 443, "apc ups ssl webui port number")
+	cfg.install.skipVerify = installFlags.BoolLong("skipverify", "the tool will try to connect to the UPS web UI to verify install success; this flag disables that check")
 	cfg.install.insecureCipher = installFlags.BoolLong("insecurecipher", "allows the use of insecure ssh ciphers (NOT recommended)")
 
 	installCmd := &ff.Command{
 		Name:      "install",
-		Usage:     "apc-p15-tool upload --keyfile key.pem --certfile cert.pem --apchost example.com:22 --fingerprint 123abc --username apc --password test",
+		Usage:     "apc-p15-tool install --keyfile key.pem --certfile cert.pem --hostname example.com --fingerprint 123abc --username apc --password test",
 		ShortHelp: "install the specified key and cert pem files on an apc ups (they will be converted to a comaptible p15 file)",
 		Flags:     installFlags,
 		Exec:      app.cmdInstall,
diff --git a/pkg/app/file_header.go b/pkg/app/file_header.go
index b803d68..20097cd 100644
--- a/pkg/app/file_header.go
+++ b/pkg/app/file_header.go
@@ -7,6 +7,8 @@ import (
 	"github.com/sigurn/crc16"
 )
 
+const apcHeaderLen = 228
+
 // makeFileHeader generates the 228 byte header to prepend to the .p15
 // as required by APC UPS NMC. Contrary to the apc_tools repo, it does
 // mot appear the header changes based on key size.
@@ -28,7 +30,7 @@ func makeFileHeader(p15File []byte) ([]byte, error) {
 	// 		*(uint32_t *)(buf + 208) = keySize; // 1 for 1024 key, otherwise (2048 bit) 2
 	// Unsure why this was in original code but seems irrelevant
 
-	header := make([]byte, 228)
+	header := make([]byte, apcHeaderLen)
 
 	// always 1
 	header[0] = 1
diff --git a/pkg/app/pem_to_p15.go b/pkg/app/pem_to_p15.go
index eb020aa..d48d4a8 100644
--- a/pkg/app/pem_to_p15.go
+++ b/pkg/app/pem_to_p15.go
@@ -3,36 +3,63 @@ package app
 import (
 	"apc-p15-tool/pkg/pkcs15"
 	"fmt"
+	"slices"
 )
 
-// pemToAPCP15 reads the specified pem files and returns the apc p15 bytes
-func (app *app) pemToAPCP15(keyPem, certPem []byte, parentCmdName string) ([]byte, error) {
-	app.stdLogger.Printf("%s: making apc p15 file from pem", parentCmdName)
+// list of keys supported by the NMC2
+var nmc2SupportedKeyTypes = []pkcs15.KeyType{
+	pkcs15.KeyTypeRSA1024,
+	pkcs15.KeyTypeRSA2048,
+	pkcs15.KeyTypeRSA3072, // officially not supported but works
+}
+
+// pemToAPCP15 reads the specified pem files and returns the apc p15 file(s). If the
+// key type of the key is not supported by NMC2, the combined key+cert file is not
+// generated and nil is returned instead for that file. If the key IS supported by
+// NMC2, the key+cert file is generated and the proper header is prepended.
+func (app *app) pemToAPCP15(keyPem, certPem []byte, parentCmdName string) (keyFile []byte, apcKeyCertFile []byte, err error) {
+	app.stdLogger.Printf("%s: making apc p15 file(s) content from pem", parentCmdName)
 
 	// make p15 struct
 	p15, err := pkcs15.ParsePEMToPKCS15(keyPem, certPem)
 	if err != nil {
-		return nil, fmt.Errorf("%s: failed to parse pem files (%w)", parentCmdName, err)
+		return nil, nil, fmt.Errorf("%s: failed to parse pem files (%w)", parentCmdName, err)
 	}
 
-	app.stdLogger.Printf("%s: successfully loaded pem files", parentCmdName)
+	app.stdLogger.Printf("%s: successfully parsed pem files", parentCmdName)
 
-	// make file bytes
-	p15File, err := p15.ToP15File()
+	// make key file (always)
+	keyFile, err = p15.ToP15Key()
 	if err != nil {
-		return nil, fmt.Errorf("%s: failed to make p15 file (%w)", parentCmdName, err)
+		return nil, nil, fmt.Errorf("%s: failed to make p15 key file (%w)", parentCmdName, err)
 	}
 
-	// make header for file bytes
-	apcHeader, err := makeFileHeader(p15File)
-	if err != nil {
-		return nil, fmt.Errorf("%s: failed to make p15 file header (%w)", parentCmdName, err)
+	app.stdLogger.Printf("%s: successfully generated p15 key file content", parentCmdName)
+
+	// check key type for compat with NMC2
+	if slices.Contains(nmc2SupportedKeyTypes, p15.KeyType()) {
+		app.stdLogger.Printf("%s: key type is supported by NMC2, generating p15 key+cert file content...", parentCmdName)
+
+		// make file bytes
+		keyCertFile, err := p15.ToP15KeyCert()
+		if err != nil {
+			return nil, nil, fmt.Errorf("%s: failed to make p15 key+cert file content (%w)", parentCmdName, err)
+		}
+
+		// make header for file bytes
+		apcHeader, err := makeFileHeader(keyCertFile)
+		if err != nil {
+			return nil, nil, fmt.Errorf("%s: failed to make p15 key+cert file header (%w)", parentCmdName, err)
+		}
+
+		// combine header with file
+		apcKeyCertFile = append(apcHeader, keyCertFile...)
+	} else {
+		// NMC2 unsupported
+		app.stdLogger.Printf("%s: key type is not supported by NMC2, skipping p15 key+cert file content", parentCmdName)
 	}
 
-	// combine header with file
-	apcFile := append(apcHeader, p15File...)
+	app.stdLogger.Printf("%s: apc p15 file(s) data succesfully generated", parentCmdName)
 
-	app.stdLogger.Printf("%s: apc p15 file data succesfully generated", parentCmdName)
-
-	return apcFile, nil
+	return keyFile, apcKeyCertFile, nil
 }
diff --git a/pkg/app/ssh_resetwebui.go b/pkg/app/ssh_resetwebui.go
deleted file mode 100644
index 52f90d9..0000000
--- a/pkg/app/ssh_resetwebui.go
+++ /dev/null
@@ -1,45 +0,0 @@
-package app
-
-import (
-	"errors"
-	"fmt"
-
-	"golang.org/x/crypto/ssh"
-)
-
-// sshResetUPSWebUI sends a command to the UPS to restart the WebUI. This
-// command is supposed to be required to load the new cert, but that
-// doesn't seem to be true (at least it isn't on my UPS). Adding the
-// option though, in case other UPS might need it.
-func sshResetUPSWebUI(client *ssh.Client) error {
-	// make session to use for restart command
-	session, err := client.NewSession()
-	if err != nil {
-		return fmt.Errorf("ssh: restart: failed to create session (%w)", err)
-	}
-	defer session.Close()
-
-	// start shell
-	err = session.Shell()
-	if err != nil {
-		return fmt.Errorf("ssh: restart: failed to start shell (%w)", err)
-	}
-
-	// execure reboot via SendRequest
-	payload := []byte("reboot -Y")
-	payloadLen := uint8(len(payload))
-	payload = append([]byte{0, 0, 0, payloadLen}, payload...)
-
-	ok, err := session.SendRequest("exec", true, payload)
-	if err != nil {
-		return fmt.Errorf("ssh: scp: failed to execute scp cmd (%w)", err)
-	}
-	if !ok {
-		return errors.New("ssh: scp: execute scp cmd not ok")
-	}
-
-	// don't read remote output, as nothing interesting actually outputs
-
-	// done
-	return nil
-}
diff --git a/pkg/app/ssh_response.go b/pkg/app/ssh_response.go
deleted file mode 100644
index 3b44523..0000000
--- a/pkg/app/ssh_response.go
+++ /dev/null
@@ -1,34 +0,0 @@
-package app
-
-import (
-	"bufio"
-	"fmt"
-	"io"
-)
-
-// sshCheckResponse reads the output from the remote and returns an error
-// if the remote output was not 0
-func sshCheckResponse(remoteOutPipe io.Reader) error {
-	buffer := make([]uint8, 1)
-	_, err := remoteOutPipe.Read(buffer)
-	if err != nil {
-		return fmt.Errorf("ssh: failed to make read output buffer (%w)", err)
-	}
-
-	responseType := buffer[0]
-	message := ""
-	if responseType > 0 {
-		bufferedReader := bufio.NewReader(remoteOutPipe)
-		message, err = bufferedReader.ReadString('\n')
-		if err != nil {
-			return fmt.Errorf("ssh: failed to read output buffer (%w)", err)
-		}
-	}
-
-	// if not 0 (aka OK)
-	if responseType != 0 {
-		return fmt.Errorf("ssh: remote returned error (%d: %s)", responseType, message)
-	}
-
-	return nil
-}
diff --git a/pkg/app/ssh_scp.go b/pkg/app/ssh_scp.go
deleted file mode 100644
index 54f216c..0000000
--- a/pkg/app/ssh_scp.go
+++ /dev/null
@@ -1,105 +0,0 @@
-package app
-
-import (
-	"bytes"
-	"errors"
-	"fmt"
-	"io"
-	"path"
-	"time"
-
-	"golang.org/x/crypto/ssh"
-)
-
-// APC UPS won't except Go's SSH "Run()" command as the format isn't quite
-// the same. Therefore, write a custom implementation to send the desired
-// command instead of relying on something like github.com/bramvdbogaerde/go-scp
-
-const (
-	sshScpP15Destination    = "/ssl/defaultcert.p15"
-	sshScpP15PermissionsStr = "0600"
-
-	sshScpTimeout = 90 * time.Second
-)
-
-// sshScpSendFileToUPS sends the p15File to the APC UPS via the SCP protocol. it is
-// automatically placed in the correct directory and will overwrite any existing
-// file
-func sshScpSendFileToUPS(client *ssh.Client, p15File []byte) error {
-	// make session to use for SCP
-	session, err := client.NewSession()
-	if err != nil {
-		return fmt.Errorf("ssh: scp: failed to create session (%w)", err)
-	}
-	defer session.Close()
-
-	// attach pipes
-	out, err := session.StdoutPipe()
-	if err != nil {
-		return err
-	}
-	w, err := session.StdinPipe()
-	if err != nil {
-		return err
-	}
-	defer w.Close()
-
-	// send execute cmd --
-	// build cmd to send as request
-	// Go implementation sends additional 0x22 bytes when using Run() (as
-	// compared to putty's scp tool). these additional bytes seem to cause the
-	// apc ups to fail execution of the command
-	payload := []byte(fmt.Sprintf("scp -q -t %s", sshScpP15Destination))
-	payloadLen := uint8(len(payload))
-	payload = append([]byte{0, 0, 0, payloadLen}, payload...)
-
-	ok, err := session.SendRequest("exec", true, payload)
-	if err != nil {
-		return fmt.Errorf("ssh: scp: failed to execute scp cmd (%w)", err)
-	}
-	if !ok {
-		return errors.New("ssh: scp: execute scp cmd not ok")
-	}
-
-	// check remote response
-	// Note: File upload may not work if the client doesn't actually read from
-	// the remote output.
-	err = sshCheckResponse(out)
-	if err != nil {
-		return fmt.Errorf("ssh: scp: failed to send scp cmd (bad remote response) (%w)", err)
-	}
-
-	// just file name (without path)
-	filename := path.Base(sshScpP15Destination)
-
-	// send file header
-	_, err = fmt.Fprintln(w, "C"+sshScpP15PermissionsStr, len(p15File), filename)
-	if err != nil {
-		return fmt.Errorf("ssh: scp: failed to send file info (%w)", err)
-	}
-
-	err = sshCheckResponse(out)
-	if err != nil {
-		return fmt.Errorf("ssh: scp: failed to send file info (bad remote response) (%w)", err)
-	}
-
-	// send actual file
-	_, err = io.Copy(w, bytes.NewReader(p15File))
-	if err != nil {
-		return fmt.Errorf("ssh: scp: failed to send file(%w)", err)
-	}
-
-	// send file end
-	_, err = fmt.Fprint(w, "\x00")
-	if err != nil {
-		return fmt.Errorf("ssh: scp: failed to send final 00 byte (%w)", err)
-	}
-
-	err = sshCheckResponse(out)
-	if err != nil {
-		return fmt.Errorf("ssh: scp: failed to send file (bad remote response) (%w)", err)
-	}
-
-	// done
-	return nil
-}
diff --git a/pkg/pkcs15/encrypted_envelope.go b/pkg/pkcs15/encrypted_envelope.go
index b0eef9d..71433d1 100644
--- a/pkg/pkcs15/encrypted_envelope.go
+++ b/pkg/pkcs15/encrypted_envelope.go
@@ -21,14 +21,19 @@ const (
 	apcKEKIterations = 5000
 )
 
-// encryptedKeyEnvelope encrypts p15's rsa private key using the algorithms and
-// params expected in the APC file. Salt values are always random.
-func (p15 *pkcs15KeyCert) encryptedKeyEnvelope() ([]byte, error) {
+// encryptedKeyEnvelope encrypts p15's private key using the algorithms and
+// params expected in the APC file.
+func (p15 *pkcs15KeyCert) computeEncryptedKeyEnvelope() error {
+	// if computation already performed, this is a no-op (keep existing envelope)
+	if p15.envelopedPrivateKey != nil && len(p15.envelopedPrivateKey) != 0 {
+		return nil
+	}
+
 	// calculate values for the object
 	kekSalt := make([]byte, 8)
 	_, err := rand.Read(kekSalt)
 	if err != nil {
-		return nil, err
+		return err
 	}
 
 	// kek hash alg
@@ -42,7 +47,7 @@ func (p15 *pkcs15KeyCert) encryptedKeyEnvelope() ([]byte, error) {
 	// make DES cipher from KEK for CEK
 	cekDesCipher, err := des.NewTripleDESCipher(kek)
 	if err != nil {
-		return nil, err
+		return err
 	}
 
 	// cek (16 bytes for authEnc128) -- see: rfc3211
@@ -50,7 +55,7 @@ func (p15 *pkcs15KeyCert) encryptedKeyEnvelope() ([]byte, error) {
 	cek := make([]byte, cekLen)
 	_, err = rand.Read(cek)
 	if err != nil {
-		return nil, err
+		return err
 	}
 
 	// LEN + Check Val [3]
@@ -71,7 +76,7 @@ func (p15 *pkcs15KeyCert) encryptedKeyEnvelope() ([]byte, error) {
 	cekPadding := make([]byte, cekPadLen)
 	_, err = rand.Read(cekPadding)
 	if err != nil {
-		return nil, err
+		return err
 	}
 
 	wrappedCEK = append(wrappedCEK, cekPadding...)
@@ -80,7 +85,7 @@ func (p15 *pkcs15KeyCert) encryptedKeyEnvelope() ([]byte, error) {
 	cekEncryptSalt := make([]byte, 8)
 	_, err = rand.Read(cekEncryptSalt)
 	if err != nil {
-		return nil, err
+		return err
 	}
 
 	cekEncrypter := cipher.NewCBCEncrypter(cekDesCipher, cekEncryptSalt)
@@ -94,13 +99,13 @@ func (p15 *pkcs15KeyCert) encryptedKeyEnvelope() ([]byte, error) {
 	contentEncSalt := make([]byte, 8)
 	_, err = rand.Read(contentEncSalt)
 	if err != nil {
-		return nil, err
+		return err
 	}
 
 	contentEncryptKey := pbkdf2.Key(cek, []byte("encryption"), 1, 24, sha1.New)
 	contentDesCipher, err := des.NewTripleDESCipher(contentEncryptKey)
 	if err != nil {
-		return nil, err
+		return err
 	}
 
 	// envelope content (that will be encrypted)
@@ -120,9 +125,6 @@ func (p15 *pkcs15KeyCert) encryptedKeyEnvelope() ([]byte, error) {
 	encryptedContent := make([]byte, len(content))
 	contentEncrypter.CryptBlocks(encryptedContent, content)
 
-	// encrypted content MAC
-	macKey := pbkdf2.Key(cek, []byte("authentication"), 1, 32, sha1.New)
-
 	// data encryption alg block
 	encAlgObj := asn1obj.Sequence([][]byte{
 		// ContentEncryptionAlgorithmIdentifier
@@ -144,6 +146,9 @@ func (p15 *pkcs15KeyCert) encryptedKeyEnvelope() ([]byte, error) {
 		}),
 	})
 
+	// encrypted content MAC
+	macKey := pbkdf2.Key(cek, []byte("authentication"), 1, 32, sha1.New)
+
 	macHasher := hmac.New(sha256.New, macKey)
 	// the data the MAC covers is the algId header bytes + encrypted data bytes
 	hashMe := append(encAlgObj, encryptedContent...)
@@ -151,7 +156,7 @@ func (p15 *pkcs15KeyCert) encryptedKeyEnvelope() ([]byte, error) {
 	// make MAC
 	_, err = macHasher.Write(hashMe)
 	if err != nil {
-		return nil, err
+		return err
 	}
 	mac := macHasher.Sum(nil)
 
@@ -218,5 +223,7 @@ func (p15 *pkcs15KeyCert) encryptedKeyEnvelope() ([]byte, error) {
 		finalEnv = append(finalEnv, envelope[i]...)
 	}
 
-	return finalEnv, nil
+	// set p15 struct envelope
+	p15.envelopedPrivateKey = finalEnv
+	return nil
 }
diff --git a/pkg/pkcs15/keyid.go b/pkg/pkcs15/keyid.go
index 76f4297..08a3ce4 100644
--- a/pkg/pkcs15/keyid.go
+++ b/pkg/pkcs15/keyid.go
@@ -2,6 +2,8 @@ package pkcs15
 
 import (
 	"apc-p15-tool/pkg/tools/asn1obj"
+	"crypto/ecdsa"
+	"crypto/rsa"
 	"crypto/sha1"
 	"encoding/binary"
 	"math/big"
@@ -11,22 +13,6 @@ import (
 func (p15 *pkcs15KeyCert) keyId() []byte {
 	// object to hash is just the RawSubjectPublicKeyInfo
 
-	// Create Object to hash
-	// hashObj := asn1obj.Sequence([][]byte{
-	// 	asn1obj.Sequence([][]byte{
-	// 		// Key is RSA
-	// 		asn1obj.ObjectIdentifier(asn1obj.OIDrsaEncryptionPKCS1),
-	// 		asn1.NullBytes,
-	// 	}),
-	// 	// BIT STRING of rsa key public key
-	// 	asn1obj.BitString(
-	// 		asn1obj.Sequence([][]byte{
-	// 			asn1obj.Integer(p15.key.N),
-	// 			asn1obj.Integer((big.NewInt(int64(p15.key.E)))),
-	// 		}),
-	// 	),
-	// })
-
 	// SHA-1 Hash
 	hasher := sha1.New()
 	_, err := hasher.Write(p15.cert.RawSubjectPublicKeyInfo)
@@ -124,18 +110,32 @@ func (p15 *pkcs15KeyCert) keyIdInt7() []byte {
 }
 
 // keyIdInt8 returns the sequence for keyId with INT val of 8; This value is equivelant
-// to "pgp", which is PGP v3 key Id. This value is just the last 8 bytes of the public
-// key N value
+// to "pgp", which is PGP v3 key Id.
 func (p15 *pkcs15KeyCert) keyIdInt8() []byte {
-	nBytes := p15.key.N.Bytes()
+	var keyIdVal []byte
+
+	switch privKey := p15.key.(type) {
+	case *rsa.PrivateKey:
+		// RSA: The ID value is just the last 8 bytes of the public key N value
+		nBytes := privKey.N.Bytes()
+		keyIdVal = nBytes[len(nBytes)-8:]
+
+	case *ecdsa.PrivateKey:
+		// don't use this key id, leave empty
+		return nil
+
+	default:
+		// panic if unexpected key type
+		panic("key id 8 for key is unexpected and unsupported")
+	}
 
 	// object to return
-	obj := asn1obj.Sequence([][]byte{
+	idObj := asn1obj.Sequence([][]byte{
 		asn1obj.Integer(big.NewInt(8)),
-		asn1obj.OctetString(nBytes[len(nBytes)-8:]),
+		asn1obj.OctetString(keyIdVal),
 	})
 
-	return obj
+	return idObj
 }
 
 // bigIntToMpi returns the MPI (as defined in RFC 4880 s 3.2) from a given
@@ -156,33 +156,44 @@ func (p15 *pkcs15KeyCert) keyIdInt9() []byte {
 	// Public-Key packet starting with the version field.  The Key ID is the
 	// low-order 64 bits of the fingerprint.
 
-	// the entire Public-Key packet
+	// first make the public key packet
 	publicKeyPacket := []byte{}
 
 	// starting with the version field (A one-octet version number (4)).
 	publicKeyPacket = append(publicKeyPacket, byte(4))
 
 	// A four-octet number denoting the time that the key was created.
-	time := make([]byte, 4)
-
 	// NOTE: use cert validity start as proxy for key creation since key pem
 	// doesn't actually contain a created at time -- in reality notBefore tends
 	// to be ~ 1 hour ish BEFORE the cert was even created. Key would also
 	// obviously have to be created prior to the cert creation.
+	time := make([]byte, 4)
 	binary.BigEndian.PutUint32(time, uint32(p15.cert.NotBefore.Unix()))
 	publicKeyPacket = append(publicKeyPacket, time...)
 
-	// A one-octet number denoting the public-key algorithm of this key.
-	// 1 - RSA (Encrypt or Sign) [HAC]
-	publicKeyPacket = append(publicKeyPacket, byte(1))
+	// the next part is key type specific
+	switch privKey := p15.key.(type) {
+	case *rsa.PrivateKey:
+		// A one-octet number denoting the public-key algorithm of this key.
+		// 1 - RSA (Encrypt or Sign) [HAC]
+		publicKeyPacket = append(publicKeyPacket, byte(1))
 
-	// Algorithm-Specific Fields for RSA public keys:
-	// multiprecision integer (MPI) of RSA public modulus n
-	publicKeyPacket = append(publicKeyPacket, bigIntToMpi(p15.key.N)...)
+		// Algorithm-Specific Fields for RSA public keys:
+		// multiprecision integer (MPI) of RSA public modulus n
+		publicKeyPacket = append(publicKeyPacket, bigIntToMpi(privKey.N)...)
 
-	// MPI of RSA public encryption exponent e
-	e := big.NewInt(int64(p15.key.PublicKey.E))
-	publicKeyPacket = append(publicKeyPacket, bigIntToMpi(e)...)
+		// MPI of RSA public encryption exponent e
+		e := big.NewInt(int64(privKey.PublicKey.E))
+		publicKeyPacket = append(publicKeyPacket, bigIntToMpi(e)...)
+
+	case *ecdsa.PrivateKey:
+		// don't use this key id, leave empty
+		return nil
+
+	default:
+		// panic if unexpected key type
+		panic("key id 9 for key is unexpected and unsupported")
+	}
 
 	// Assemble the V4 byte array that will be hashed
 	// 0x99 (1 octet)
@@ -205,10 +216,10 @@ func (p15 *pkcs15KeyCert) keyIdInt9() []byte {
 	keyId := sha1Hash[len(sha1Hash)-8:]
 
 	// object to return
-	obj := asn1obj.Sequence([][]byte{
+	idObj := asn1obj.Sequence([][]byte{
 		asn1obj.Integer(big.NewInt(9)),
 		asn1obj.OctetString(keyId),
 	})
 
-	return obj
+	return idObj
 }
diff --git a/pkg/pkcs15/pem_decode.go b/pkg/pkcs15/pem_decode.go
index d4b8764..2d51837 100644
--- a/pkg/pkcs15/pem_decode.go
+++ b/pkg/pkcs15/pem_decode.go
@@ -1,28 +1,37 @@
 package pkcs15
 
 import (
+	"crypto"
+	"crypto/ecdsa"
 	"crypto/rsa"
 	"crypto/tls"
 	"crypto/x509"
 	"encoding/pem"
 	"errors"
 	"fmt"
+	"reflect"
+	"slices"
 )
 
 var (
 	errPemKeyBadBlock       = errors.New("pkcs15: pem key: failed to decode pem block")
 	errPemKeyFailedToParse  = errors.New("pkcs15: pem key: failed to parse key")
-	errPemKeyWrongBlockType = errors.New("pkcs15: pem key: unsupported pem block type (only pkcs1 and pkcs8 supported)")
-	errPemKeyWrongType      = errors.New("pkcs15: pem key: unsupported key type (only rsa 1,024 or 2,048 supported)")
+	errPemKeyWrongBlockType = errors.New("pkcs15: pem key: unsupported pem block type")
+	errKeyWrongType         = errors.New("pkcs15: pem key: unsupported key type")
 
 	errPemCertBadBlock      = errors.New("pkcs15: pem cert: failed to decode pem block")
 	errPemCertFailedToParse = errors.New("pkcs15: pem cert: failed to parse cert")
 )
 
+var (
+	supportedRSASizes    = []int{1024, 2048, 3072, 4096}
+	supportedECDSACurves = []string{"P-256", "P-384", "P-521"}
+)
+
 // pemKeyDecode attempts to decode a pem encoded byte slice and then attempts
-// to parse an RSA private key from the decoded pem block. an error is returned
-// if any of these steps fail OR if the key is not RSA and of bitlen 1,024 or 2,048
-func pemKeyDecode(keyPem []byte) (*rsa.PrivateKey, error) {
+// to parse a private key from the decoded pem block. an error is returned
+// if any of these steps fail OR if the key is not supported.
+func pemKeyDecode(keyPem []byte) (crypto.PrivateKey, error) {
 	// decode
 	pemBlock, _ := pem.Decode([]byte(keyPem))
 	if pemBlock == nil {
@@ -30,13 +39,11 @@ func pemKeyDecode(keyPem []byte) (*rsa.PrivateKey, error) {
 	}
 
 	// parsing depends on block type
-	var rsaKey *rsa.PrivateKey
+	var privateKey crypto.PrivateKey
 
 	switch pemBlock.Type {
 	case "RSA PRIVATE KEY": // PKCS1
-		var err error
-
-		rsaKey, err = x509.ParsePKCS1PrivateKey(pemBlock.Bytes)
+		rsaKey, err := x509.ParsePKCS1PrivateKey(pemBlock.Bytes)
 		if err != nil {
 			return nil, errPemKeyFailedToParse
 		}
@@ -47,12 +54,27 @@ func pemKeyDecode(keyPem []byte) (*rsa.PrivateKey, error) {
 			return nil, fmt.Errorf("pkcs15: pem key: failed sanity check (%s)", err)
 		}
 
-		// verify proper bitlen
-		if rsaKey.N.BitLen() != 1024 && rsaKey.N.BitLen() != 2048 {
-			return nil, errPemKeyWrongType
+		// verify supported rsa bitlen
+		if !slices.Contains(supportedRSASizes, rsaKey.N.BitLen()) {
+			return nil, errKeyWrongType
 		}
 
 		// good to go
+		privateKey = rsaKey
+
+	case "EC PRIVATE KEY": // SEC1, ASN.1
+		ecdKey, err := x509.ParseECPrivateKey(pemBlock.Bytes)
+		if err != nil {
+			return nil, errPemKeyFailedToParse
+		}
+
+		// verify supported curve name
+		if !slices.Contains(supportedECDSACurves, ecdKey.Curve.Params().Name) {
+			return nil, errKeyWrongType
+		}
+
+		// good to go
+		privateKey = ecdKey
 
 	case "PRIVATE KEY": // PKCS8
 		pkcs8Key, err := x509.ParsePKCS8PrivateKey(pemBlock.Bytes)
@@ -62,23 +84,31 @@ func pemKeyDecode(keyPem []byte) (*rsa.PrivateKey, error) {
 
 		switch pkcs8Key := pkcs8Key.(type) {
 		case *rsa.PrivateKey:
-			rsaKey = pkcs8Key
-
 			// basic sanity check
-			err = rsaKey.Validate()
+			err = pkcs8Key.Validate()
 			if err != nil {
 				return nil, fmt.Errorf("pkcs15: pem key: failed sanity check (%s)", err)
 			}
 
-			// verify proper bitlen
-			if rsaKey.N.BitLen() != 1024 && rsaKey.N.BitLen() != 2048 {
-				return nil, errPemKeyWrongType
+			// verify supported rsa bitlen
+			if !slices.Contains(supportedRSASizes, pkcs8Key.N.BitLen()) {
+				return nil, errKeyWrongType
 			}
 
 			// good to go
+			privateKey = pkcs8Key
+
+		case *ecdsa.PrivateKey:
+			// verify supported curve name
+			if !slices.Contains(supportedECDSACurves, pkcs8Key.Curve.Params().Name) {
+				return nil, errKeyWrongType
+			}
+
+			// good to go
+			privateKey = pkcs8Key
 
 		default:
-			return nil, errPemKeyWrongType
+			return nil, errKeyWrongType
 		}
 
 	default:
@@ -86,12 +116,12 @@ func pemKeyDecode(keyPem []byte) (*rsa.PrivateKey, error) {
 	}
 
 	// if rsaKey is nil somehow, error
-	if rsaKey == nil {
+	if reflect.ValueOf(privateKey).IsNil() {
 		return nil, errors.New("pkcs15: pem key: rsa key unexpectedly nil (report bug to project repo)")
 	}
 
 	// success!
-	return rsaKey, nil
+	return privateKey, nil
 }
 
 // pemCertDecode attempts to decode a pem encoded byte slice and then attempts
diff --git a/pkg/pkcs15/pem_parse.go b/pkg/pkcs15/pem_parse.go
index dcd899a..19e44f1 100644
--- a/pkg/pkcs15/pem_parse.go
+++ b/pkg/pkcs15/pem_parse.go
@@ -1,6 +1,8 @@
 package pkcs15
 
 import (
+	"crypto"
+	"crypto/ecdsa"
 	"crypto/rsa"
 	"crypto/x509"
 )
@@ -8,8 +10,61 @@ import (
 // pkcs15KeyCert holds the data for a key and certificate pair; it provides
 // various methods to transform pkcs15 data
 type pkcs15KeyCert struct {
-	key  *rsa.PrivateKey
+	key  crypto.PrivateKey
 	cert *x509.Certificate
+	// store the encrypted enveloped Private Key for re-use
+	envelopedPrivateKey []byte
+}
+
+// KeyType is used by consumers to check for compatibility
+type KeyType int
+
+const (
+	KeyTypeRSA1024 KeyType = iota
+	KeyTypeRSA2048
+	KeyTypeRSA3072
+	KeyTypeRSA4096
+
+	KeyTypeECP256
+	KeyTypeECP384
+	KeyTypeECP521
+
+	KeyTypeUnknown
+)
+
+// KeyType returns the private key type
+func (p15 *pkcs15KeyCert) KeyType() KeyType {
+	switch pKey := p15.key.(type) {
+	case *rsa.PrivateKey:
+		switch pKey.N.BitLen() {
+		case 1024:
+			return KeyTypeRSA1024
+		case 2048:
+			return KeyTypeRSA2048
+		case 3072:
+			return KeyTypeRSA3072
+		case 4096:
+			return KeyTypeRSA4096
+
+		default:
+		}
+
+	case *ecdsa.PrivateKey:
+		switch pKey.Curve.Params().Name {
+		case "P-256":
+			return KeyTypeECP256
+		case "P-384":
+			return KeyTypeECP384
+		case "P-521":
+			return KeyTypeECP521
+
+		default:
+		}
+
+	default:
+	}
+
+	return KeyTypeUnknown
 }
 
 // ParsePEMToPKCS15 parses the provide pem files to a pkcs15 struct; it also does some
@@ -27,10 +82,17 @@ func ParsePEMToPKCS15(keyPem, certPem []byte) (*pkcs15KeyCert, error) {
 		return nil, err
 	}
 
+	// create p15 struct
 	p15 := &pkcs15KeyCert{
 		key:  key,
 		cert: cert,
 	}
 
+	// pre-calculate encrypted envelope
+	err = p15.computeEncryptedKeyEnvelope()
+	if err != nil {
+		return nil, err
+	}
+
 	return p15, nil
 }
diff --git a/pkg/pkcs15/pem_to_p15.go b/pkg/pkcs15/pem_to_p15.go
index a7a9fef..0c2214d 100644
--- a/pkg/pkcs15/pem_to_p15.go
+++ b/pkg/pkcs15/pem_to_p15.go
@@ -2,6 +2,10 @@ package pkcs15
 
 import (
 	"apc-p15-tool/pkg/tools/asn1obj"
+	"crypto/ecdsa"
+	"crypto/rsa"
+	"encoding/asn1"
+	"fmt"
 	"math/big"
 )
 
@@ -9,100 +13,89 @@ const (
 	apcKeyLabel = "Private key"
 )
 
-// ToP15File turns the key and cert into a properly formatted and encoded
-// p15 file
-func (p15 *pkcs15KeyCert) ToP15File() ([]byte, error) {
-	// private key object
-	pkey, err := p15.toP15PrivateKey()
+// toP15KeyCert creates a P15 file with both the private key and certificate, mirroring the
+// final p15 file an APC UPS expects (though without the header)
+func (p15 *pkcs15KeyCert) ToP15KeyCert() (keyCert []byte, err error) {
+	// encrypted envelope is required
+	err = p15.computeEncryptedKeyEnvelope()
 	if err != nil {
 		return nil, err
 	}
 
-	cert, err := p15.toP15Cert()
-	if err != nil {
-		return nil, err
-	}
+	// create private key object
+	var privKeyObj []byte
 
-	// ContentInfo
-	p15File := asn1obj.Sequence([][]byte{
-
-		// contentType: OID: 1.2.840.113549.1.15.3.1 pkcs15content (PKCS #15 content type)
-		asn1obj.ObjectIdentifier(asn1obj.OIDPkscs15Content),
-
-		// content
-		asn1obj.ExplicitCompound(0, [][]byte{
+	switch p15.key.(type) {
+	case *rsa.PrivateKey:
+		// private key object
+		privKeyObj =
 			asn1obj.Sequence([][]byte{
-				asn1obj.Integer(big.NewInt(0)),
+				// commonObjectAttributes - Label
 				asn1obj.Sequence([][]byte{
-					asn1obj.ExplicitCompound(0, [][]byte{
-						asn1obj.ExplicitCompound(0, [][]byte{
-							pkey,
-						}),
-					}),
-					asn1obj.ExplicitCompound(4, [][]byte{
-						asn1obj.ExplicitCompound(0, [][]byte{
-							cert,
+					asn1obj.UTF8String(apcKeyLabel),
+				}),
+				// CommonKeyAttributes
+				asn1obj.Sequence([][]byte{
+					// CommonKeyAttributes - iD - uses keyId that is SHA1( SubjectPublicKeyInfo SEQUENCE )
+					asn1obj.OctetString(p15.keyId()),
+					// CommonKeyAttributes - usage (trailing 0s will drop)
+					asn1obj.BitString([]byte{byte(0b11100010)}),
+					// CommonKeyAttributes - accessFlags (trailing 0s will drop)
+					asn1obj.BitString([]byte{byte(0b10110000)}),
+					// CommonKeyAttributes - startDate
+					asn1obj.GeneralizedTime(p15.cert.NotBefore),
+					// CommonKeyAttributes - [0] endDate
+					asn1obj.GeneralizedTimeExplicitValue(0, p15.cert.NotAfter),
+				}),
+				// ObjectValue - indirect-protected
+				asn1obj.ExplicitCompound(1, [][]byte{
+					asn1obj.Sequence([][]byte{
+						// AuthEnvelopedData Type ([4])
+						asn1obj.ExplicitCompound(4, [][]byte{
+							p15.envelopedPrivateKey,
 						}),
 					}),
 				}),
-			}),
-		}),
-	})
+			})
 
-	return p15File, nil
-}
+	case *ecdsa.PrivateKey:
+		privKeyObj =
+			asn1obj.ExplicitCompound(0, [][]byte{
+				// commonObjectAttributes - Label
+				asn1obj.Sequence([][]byte{
+					asn1obj.UTF8String(apcKeyLabel),
+				}),
+				// CommonKeyAttributes
+				asn1obj.Sequence([][]byte{
+					// CommonKeyAttributes - iD - uses keyId that is SHA1( SubjectPublicKeyInfo SEQUENCE )
+					asn1obj.OctetString(p15.keyId()),
+					// CommonKeyAttributes - usage (trailing 0s will drop)
+					asn1obj.BitString([]byte{byte(0b00100010)}),
+					// CommonKeyAttributes - accessFlags (trailing 0s will drop)
+					asn1obj.BitString([]byte{byte(0b10110000)}),
+					// CommonKeyAttributes - startDate
+					asn1obj.GeneralizedTime(p15.cert.NotBefore),
+					// CommonKeyAttributes - [0] endDate
+					asn1obj.GeneralizedTimeExplicitValue(0, p15.cert.NotAfter),
+				}),
+				// ObjectValue - indirect-protected
+				asn1obj.ExplicitCompound(1, [][]byte{
+					asn1obj.Sequence([][]byte{
+						// AuthEnvelopedData Type ([4])
+						asn1obj.ExplicitCompound(4, [][]byte{
+							p15.envelopedPrivateKey,
+						}),
+					}),
+				}),
+			})
 
-// toP15PrivateKey creates the encoded private key. it is broken our from the larger p15
-// function for readability
-// NOTE: Do not use this to try and turn just a private key into a p15, the format isn't
-// quite the same.
-func (p15 *pkcs15KeyCert) toP15PrivateKey() ([]byte, error) {
-	// rsa encrypted key in encrypted envelope
-	envelope, err := p15.encryptedKeyEnvelope()
-	if err != nil {
-		return nil, err
+	default:
+		// bad key type
+		return nil, errKeyWrongType
 	}
 
-	// key object
-	key := asn1obj.Sequence([][]byte{
-		// commonObjectAttributes - Label
-		asn1obj.Sequence([][]byte{
-			asn1obj.UTF8String(apcKeyLabel),
-		}),
-		// CommonKeyAttributes
-		asn1obj.Sequence([][]byte{
-			// CommonKeyAttributes - iD - uses keyId that is SHA1( SubjectPublicKeyInfo SEQUENCE )
-			asn1obj.OctetString(p15.keyId()),
-			// CommonKeyAttributes - usage (trailing 0s will drop)
-			asn1obj.BitString([]byte{byte(0b11100010)}),
-			// CommonKeyAttributes - accessFlags (trailing 0s will drop)
-			asn1obj.BitString([]byte{byte(0b10110000)}),
-			// CommonKeyAttributes - startDate
-			asn1obj.GeneralizedTime(p15.cert.NotBefore),
-			// CommonKeyAttributes - [0] endDate
-			asn1obj.GeneralizedTimeExplicitValue(0, p15.cert.NotAfter),
-		}),
-		// ObjectValue - indirect-protected
-		asn1obj.ExplicitCompound(1, [][]byte{
-			asn1obj.Sequence([][]byte{
-				// AuthEnvelopedData Type ([4])
-				asn1obj.ExplicitCompound(4, [][]byte{
-					envelope,
-				}),
-			}),
-		}),
-	})
-
-	return key, nil
-}
-
-// toP15Cert creates the encoded certificate. it is broken our from the larger p15
-// function for readability
-// NOTE: Do not use this to try and turn just a cert into a p15. I don't believe,
-// such a thing is permissible under the spec.
-func (p15 *pkcs15KeyCert) toP15Cert() ([]byte, error) {
 	// cert object
-	cert := asn1obj.Sequence([][]byte{
+	certObj := asn1obj.Sequence([][]byte{
 		// commonObjectAttributes - Label
 		asn1obj.Sequence([][]byte{
 			asn1obj.UTF8String(apcKeyLabel),
@@ -116,6 +109,7 @@ func (p15 *pkcs15KeyCert) toP15Cert() ([]byte, error) {
 				p15.keyIdInt3(),
 				p15.keyIdInt6(),
 				p15.keyIdInt7(),
+				// 8 & 9 will return nil for EC keys (effectively omitting them)
 				p15.keyIdInt8(),
 				p15.keyIdInt9(),
 			}),
@@ -134,5 +128,246 @@ func (p15 *pkcs15KeyCert) toP15Cert() ([]byte, error) {
 		}),
 	})
 
-	return cert, nil
+	// build the object
+
+	// ContentInfo
+	keyCert = asn1obj.Sequence([][]byte{
+
+		// contentType: OID: 1.2.840.113549.1.15.3.1 pkcs15content (PKCS #15 content type)
+		asn1obj.ObjectIdentifier(asn1obj.OIDPkscs15Content),
+
+		// content
+		asn1obj.ExplicitCompound(0, [][]byte{
+			asn1obj.Sequence([][]byte{
+				asn1obj.Integer(big.NewInt(0)),
+				asn1obj.Sequence([][]byte{
+					asn1obj.ExplicitCompound(0, [][]byte{
+						asn1obj.ExplicitCompound(0, [][]byte{
+							privKeyObj,
+						}),
+					}),
+					asn1obj.ExplicitCompound(4, [][]byte{
+						asn1obj.ExplicitCompound(0, [][]byte{
+							certObj,
+						}),
+					}),
+				}),
+			}),
+		}),
+	})
+
+	return keyCert, nil
+}
+
+// toP15Key creates a P15 file with just the private key, mirroring the p15 format
+// the APC tool uses when generating a new private key (Note: no header is used on
+// this file)
+func (p15 *pkcs15KeyCert) ToP15Key() (key []byte, err error) {
+	// encrypted envelope is required
+	err = p15.computeEncryptedKeyEnvelope()
+	if err != nil {
+		return nil, err
+	}
+
+	// create private and public key objects
+	var pubKeyObj, privKeyObj []byte
+
+	switch privKey := p15.key.(type) {
+	case *rsa.PrivateKey:
+		// private key object (slightly different than the key+cert format)
+		privKeyObj =
+			asn1obj.Sequence([][]byte{
+				// commonObjectAttributes - Label
+				asn1obj.Sequence([][]byte{
+					asn1obj.UTF8String(apcKeyLabel),
+				}),
+				// CommonKeyAttributes
+				asn1obj.Sequence([][]byte{
+					// CommonKeyAttributes - iD - uses keyId that is SHA1( SubjectPublicKeyInfo SEQUENCE )
+					asn1obj.OctetString(p15.keyId()),
+					// CommonKeyAttributes - usage (trailing 0s will drop)
+					asn1obj.BitString([]byte{byte(0b11100010)}),
+					// CommonKeyAttributes - accessFlags (trailing 0s will drop)
+					asn1obj.BitString([]byte{byte(0b10110000)}),
+				}),
+
+				// Key IDs
+				asn1obj.ExplicitCompound(0, [][]byte{
+					asn1obj.Sequence([][]byte{
+						asn1obj.ExplicitCompound(0, [][]byte{
+							p15.keyIdInt2(),
+							p15.keyIdInt8(),
+							p15.keyIdInt9(),
+						}),
+					}),
+				}),
+
+				// ObjectValue - indirect-protected
+				asn1obj.ExplicitCompound(1, [][]byte{
+					asn1obj.Sequence([][]byte{
+						// AuthEnvelopedData Type ([4])
+						asn1obj.ExplicitCompound(4, [][]byte{
+							p15.envelopedPrivateKey,
+						}),
+					}),
+				}),
+			})
+
+		// pub key stub
+		pubKeyObj =
+			asn1obj.Sequence([][]byte{
+				// commonObjectAttributes - Label
+				asn1obj.Sequence([][]byte{
+					asn1obj.UTF8String(apcKeyLabel),
+				}),
+				// CommonKeyAttributes
+				asn1obj.Sequence([][]byte{
+					asn1obj.OctetString(p15.keyId()),
+					asn1obj.BitString([]byte{byte(0b10000010)}),
+					asn1obj.BitString([]byte{byte(0b01000000)}),
+				}),
+
+				asn1obj.ExplicitCompound(1, [][]byte{
+					asn1obj.Sequence([][]byte{
+						asn1obj.ExplicitCompound(0, [][]byte{
+							asn1obj.ExplicitCompound(1, [][]byte{
+								asn1obj.Sequence([][]byte{
+									asn1obj.ObjectIdentifier(asn1obj.OIDrsaEncryptionPKCS1),
+									asn1.NullBytes,
+								}),
+								// RSAPublicKey SubjectPublicKeyInfo
+								asn1obj.BitString(
+									asn1obj.Sequence([][]byte{
+										asn1obj.Integer(privKey.PublicKey.N),
+										asn1obj.Integer(big.NewInt(int64(privKey.PublicKey.E))),
+									}),
+								),
+							}),
+						}),
+						// not 100% certain but appears to be rsa key byte len
+						asn1obj.Integer(big.NewInt(int64(privKey.PublicKey.N.BitLen() / 8))),
+					}),
+				}),
+			})
+
+	case *ecdsa.PrivateKey:
+		// private key object (slightly different than the key+cert format)
+		privKeyObj =
+			asn1obj.ExplicitCompound(0, [][]byte{
+				// commonObjectAttributes - Label
+				asn1obj.Sequence([][]byte{
+					asn1obj.UTF8String(apcKeyLabel),
+				}),
+				// CommonKeyAttributes
+				asn1obj.Sequence([][]byte{
+					// CommonKeyAttributes - iD - uses keyId that is SHA1( SubjectPublicKeyInfo SEQUENCE )
+					asn1obj.OctetString(p15.keyId()),
+					// CommonKeyAttributes - usage (trailing 0s will drop)
+					asn1obj.BitString([]byte{byte(0b00100010)}),
+					// CommonKeyAttributes - accessFlags (trailing 0s will drop)
+					asn1obj.BitString([]byte{byte(0b10110000)}),
+				}),
+
+				// Key IDs
+				asn1obj.ExplicitCompound(0, [][]byte{
+					asn1obj.Sequence([][]byte{
+						asn1obj.ExplicitCompound(0, [][]byte{
+							p15.keyIdInt2(),
+						}),
+					}),
+				}),
+
+				// ObjectValue - indirect-protected
+				asn1obj.ExplicitCompound(1, [][]byte{
+					asn1obj.Sequence([][]byte{
+						// AuthEnvelopedData Type ([4])
+						asn1obj.ExplicitCompound(4, [][]byte{
+							p15.envelopedPrivateKey,
+						}),
+					}),
+				}),
+			})
+
+		// convert ec pub key to a form that provides a public key bytes function
+		ecdhKey, err := privKey.PublicKey.ECDH()
+		if err != nil {
+			return nil, fmt.Errorf("failed to parse ec public key (%s)", err)
+		}
+
+		// select correct OID for curve
+		var curveOID asn1.ObjectIdentifier
+		switch privKey.Curve.Params().Name {
+		case "P-256":
+			curveOID = asn1obj.OIDprime256v1
+		case "P-384":
+			curveOID = asn1obj.OIDsecp384r1
+		case "P-521":
+			curveOID = asn1obj.OIDsecp521r1
+		default:
+			// bad curve name
+			return nil, errKeyWrongType
+		}
+
+		// pub key stub
+		pubKeyObj =
+			asn1obj.ExplicitCompound(0, [][]byte{
+				// commonObjectAttributes - Label
+				asn1obj.Sequence([][]byte{
+					asn1obj.UTF8String(apcKeyLabel),
+				}),
+				// CommonKeyAttributes
+				asn1obj.Sequence([][]byte{
+					asn1obj.OctetString(p15.keyId()),
+					asn1obj.BitString([]byte{byte(0b00000010)}),
+					asn1obj.BitString([]byte{byte(0b01000000)}),
+				}),
+
+				asn1obj.ExplicitCompound(1, [][]byte{
+					asn1obj.Sequence([][]byte{
+						asn1obj.ExplicitCompound(0, [][]byte{
+							asn1obj.Sequence([][]byte{
+								asn1obj.Sequence([][]byte{
+									asn1obj.ObjectIdentifier(asn1obj.OIDecPublicKey),
+									asn1obj.ObjectIdentifier(curveOID),
+								}),
+								asn1obj.BitString(ecdhKey.Bytes()),
+							}),
+						}),
+					}),
+				}),
+			})
+
+	default:
+		// bad key type
+		return nil, errKeyWrongType
+	}
+
+	// assemble complete object
+	key =
+		asn1obj.Sequence([][]byte{
+			// contentType: OID: 1.2.840.113549.1.15.3.1 pkcs15content (PKCS #15 content type)
+			asn1obj.ObjectIdentifier(asn1obj.OIDPkscs15Content),
+			// content
+			asn1obj.ExplicitCompound(0, [][]byte{
+				asn1obj.Sequence([][]byte{
+					asn1obj.Integer(big.NewInt(0)),
+					asn1obj.Sequence([][]byte{
+						// [0] Private Keys
+						asn1obj.ExplicitCompound(0, [][]byte{
+							asn1obj.ExplicitCompound(0, [][]byte{
+								privKeyObj,
+							}),
+						}),
+						// [1] Public Keys
+						asn1obj.ExplicitCompound(1, [][]byte{
+							asn1obj.ExplicitCompound(0, [][]byte{
+								pubKeyObj,
+							}),
+						}),
+					}),
+				}),
+			}),
+		})
+
+	return key, nil
 }
diff --git a/pkg/pkcs15/private_key.go b/pkg/pkcs15/private_key.go
index 8c6b0fb..59b538e 100644
--- a/pkg/pkcs15/private_key.go
+++ b/pkg/pkcs15/private_key.go
@@ -1,24 +1,41 @@
 package pkcs15
 
-import "apc-p15-tool/pkg/tools/asn1obj"
+import (
+	"apc-p15-tool/pkg/tools/asn1obj"
+	"crypto/ecdsa"
+	"crypto/rsa"
+)
 
 // privateKeyObject returns the ASN.1 representation of a private key
 func (p15 *pkcs15KeyCert) privateKeyObject() []byte {
-	// ensure all expected vals are available
-	p15.key.Precompute()
+	var privKeyObj []byte
 
-	pkey := asn1obj.Sequence([][]byte{
-		// P
-		asn1obj.IntegerExplicitValue(3, p15.key.Primes[0]),
-		// Q
-		asn1obj.IntegerExplicitValue(4, p15.key.Primes[1]),
-		// Dp
-		asn1obj.IntegerExplicitValue(5, p15.key.Precomputed.Dp),
-		// Dq
-		asn1obj.IntegerExplicitValue(6, p15.key.Precomputed.Dq),
-		// Qinv
-		asn1obj.IntegerExplicitValue(7, p15.key.Precomputed.Qinv),
-	})
+	switch privKey := p15.key.(type) {
+	case *rsa.PrivateKey:
+		privKey.Precompute()
 
-	return pkey
+		// ensure all expected vals are available
+		privKeyObj = asn1obj.Sequence([][]byte{
+			// P
+			asn1obj.IntegerExplicitValue(3, privKey.Primes[0]),
+			// Q
+			asn1obj.IntegerExplicitValue(4, privKey.Primes[1]),
+			// Dp
+			asn1obj.IntegerExplicitValue(5, privKey.Precomputed.Dp),
+			// Dq
+			asn1obj.IntegerExplicitValue(6, privKey.Precomputed.Dq),
+			// Qinv
+			asn1obj.IntegerExplicitValue(7, privKey.Precomputed.Qinv),
+		})
+
+	case *ecdsa.PrivateKey:
+		// Only private piece is the integer D
+		privKeyObj = asn1obj.Integer(privKey.D)
+
+	default:
+		// panic if unsupported key
+		panic("private key type is unexpected and unsupported")
+	}
+
+	return privKeyObj
 }
diff --git a/pkg/tools/asn1obj/oid.go b/pkg/tools/asn1obj/oid.go
index 3975ded..70009c2 100644
--- a/pkg/tools/asn1obj/oid.go
+++ b/pkg/tools/asn1obj/oid.go
@@ -11,6 +11,10 @@ var (
 	OIDdesEDE3CBC         = asn1.ObjectIdentifier{1, 2, 840, 113549, 3, 7}            // des-EDE3-CBC (RSADSI encryptionAlgorithm)
 	OIDpkcs7Data          = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 7, 1}         // data (PKCS #7)
 	OIDauthEnc128         = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 16, 3, 15} // authEnc128 (S/MIME Algorithms)
+	OIDecPublicKey        = asn1.ObjectIdentifier{1, 2, 840, 10045, 2, 1}             // ecPublicKey (ANSI X9.62 public key type)
+	OIDprime256v1         = asn1.ObjectIdentifier{1, 2, 840, 10045, 3, 1, 7}          //  prime256v1 (ANSI X9.62 named elliptic curve)
+	OIDsecp384r1          = asn1.ObjectIdentifier{1, 3, 132, 0, 34}                   //  secp384r1 (SECG (Certicom) named elliptic curve)
+	OIDsecp521r1          = asn1.ObjectIdentifier{1, 3, 132, 0, 35}                   //  secp521r1 (SECG (Certicom) named elliptic curve)
 )
 
 // ObjectIdentifier returns an ASN.1 OBJECT IDENTIFIER with the oidValue bytes