This is an automated email from the ASF dual-hosted git repository.

ocket8888 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficcontrol.git


The following commit(s) were added to refs/heads/master by this push:
     new 730694b8ef TPv2 Add the ability to inspect a cert/DS (#7555)
730694b8ef is described below

commit 730694b8efd8b49350d72189b43e72ef96f96013
Author: Steve Hamrick <[email protected]>
AuthorDate: Mon Jul 31 16:31:19 2023 -0600

    TPv2 Add the ability to inspect a cert/DS (#7555)
    
    * Add SSL Cert viewer
    
    * Better tests
    
    * Fix build warning and add sidebar entry
    
    * Licenses
    
    * Update package-lock after merge
    
    * Lint
    
    * Lint
    
    * Obscure private key
    
    * Forgot file
    
    * Style as textarea
    
    * Code review fixes
    
    * Fix date display
    
    * Code review changes
    
    * Use shorthand catch
    
    * Fix github merge using wrong whitespace
    
    ---------
    
    Co-authored-by: Steve Hamrick <[email protected]>
---
 experimental/traffic-portal/angular.json           |   3 +-
 experimental/traffic-portal/package-lock.json      |  24 ++-
 experimental/traffic-portal/package.json           |   2 +
 .../src/app/api/delivery-service.service.spec.ts   |  85 ++++++---
 .../src/app/api/delivery-service.service.ts        |  15 +-
 .../app/api/testing/delivery-service.service.ts    |  24 +++
 .../traffic-portal/src/app/app.ui.module.ts        |   2 +
 .../certs/cert-author/cert-author.component.html   |  41 +++++
 .../certs/cert-author/cert-author.component.scss   |  22 +++
 .../cert-author/cert-author.component.spec.ts      |  51 ++++++
 .../certs/cert-author/cert-author.component.ts     |  30 ++++
 .../certs/cert-detail/cert-detail.component.html   |  68 ++++++++
 .../certs/cert-detail/cert-detail.component.scss   |  34 ++++
 .../cert-detail/cert-detail.component.spec.ts      | 167 ++++++++++++++++++
 .../certs/cert-detail/cert-detail.component.ts     | 133 ++++++++++++++
 .../certs/cert-viewer/cert-viewer.component.html   |  77 ++++++++
 .../certs/cert-viewer/cert-viewer.component.scss   |  35 ++++
 .../cert-viewer/cert-viewer.component.spec.ts      | 161 +++++++++++++++++
 .../certs/cert-viewer/cert-viewer.component.ts     | 193 +++++++++++++++++++++
 .../src/app/core/certs/cert.util.spec.ts           |  55 ++++++
 .../traffic-portal/src/app/core/certs/cert.util.ts |  51 ++++++
 .../src/app/core/certs/certs.module.ts             |  49 ++++++
 .../traffic-portal/src/app/core/core.module.ts     |  12 +-
 .../statuses-table/statuses-table.component.html   |   2 +-
 .../statuses-table/statuses-table.component.ts     |   6 -
 .../app/shared/navigation/navigation.service.ts    |   4 +
 26 files changed, 1307 insertions(+), 39 deletions(-)

diff --git a/experimental/traffic-portal/angular.json 
b/experimental/traffic-portal/angular.json
index 1ed4616576..5b2c52a685 100644
--- a/experimental/traffic-portal/angular.json
+++ b/experimental/traffic-portal/angular.json
@@ -21,7 +21,8 @@
                                        "builder": 
"@angular-builders/custom-webpack:browser",
                                        "options": {
                                                "allowedCommonJsDependencies": [
-                                                       "chart.js"
+                                                       "chart.js",
+                                                       "node-forge"
                                                ],
                                                "customWebpackConfig": {
                                                        "path": 
"src/compress-webpack.config.js"
diff --git a/experimental/traffic-portal/package-lock.json 
b/experimental/traffic-portal/package-lock.json
index 4f9d959852..b03d8c3a76 100644
--- a/experimental/traffic-portal/package-lock.json
+++ b/experimental/traffic-portal/package-lock.json
@@ -27,6 +27,7 @@
         "chart.js": "^2.9.4",
         "compression-webpack-plugin": "^10.0.0",
         "express": "^4.15.2",
+        "node-forge": "^1.3.1",
         "rxjs": "~6.6.0",
         "trafficops-types": "^4.0.10",
         "tslib": "^2.0.0",
@@ -50,6 +51,7 @@
         "@types/jasminewd2": "~2.0.3",
         "@types/nightwatch": "^2.3.22",
         "@types/node": "^16.18.11",
+        "@types/node-forge": "^1.3.2",
         "@typescript-eslint/eslint-plugin": "^5.59.2",
         "@typescript-eslint/parser": "^5.59.2",
         "axios": "^0.27.2",
@@ -5460,6 +5462,15 @@
       "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.28.tgz";,
       "integrity": 
"sha512-SNMfiPqsiPoYfmyi+2qnDO4nZyMIOCab/CW+Slcml0lhIzkOizYzWtt/A7tgB3TSitd+YJKi8fSC2Cpm/VCp7A=="
     },
+    "node_modules/@types/node-forge": {
+      "version": "1.3.2",
+      "resolved": 
"https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.2.tgz";,
+      "integrity": 
"sha512-TzX3ahoi9xbmaoT58smrBu7oa6dQXb/+PTNCslZyD/55tlJ/osofIMClzZsoo6buDFrg7e4DvVGkZqVgv6OLxw==",
+      "dev": true,
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
     "node_modules/@types/parse-json": {
       "version": "4.0.0",
       "resolved": 
"https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz";,
@@ -15083,7 +15094,6 @@
       "version": "1.3.1",
       "resolved": 
"https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz";,
       "integrity": 
"sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==",
-      "dev": true,
       "engines": {
         "node": ">= 6.13.0"
       }
@@ -24628,6 +24638,15 @@
       "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.28.tgz";,
       "integrity": 
"sha512-SNMfiPqsiPoYfmyi+2qnDO4nZyMIOCab/CW+Slcml0lhIzkOizYzWtt/A7tgB3TSitd+YJKi8fSC2Cpm/VCp7A=="
     },
+    "@types/node-forge": {
+      "version": "1.3.2",
+      "resolved": 
"https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.2.tgz";,
+      "integrity": 
"sha512-TzX3ahoi9xbmaoT58smrBu7oa6dQXb/+PTNCslZyD/55tlJ/osofIMClzZsoo6buDFrg7e4DvVGkZqVgv6OLxw==",
+      "dev": true,
+      "requires": {
+        "@types/node": "*"
+      }
+    },
     "@types/parse-json": {
       "version": "4.0.0",
       "resolved": 
"https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz";,
@@ -31971,8 +31990,7 @@
     "node-forge": {
       "version": "1.3.1",
       "resolved": 
"https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz";,
-      "integrity": 
"sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==",
-      "dev": true
+      "integrity": 
"sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA=="
     },
     "node-gyp": {
       "version": "9.3.1",
diff --git a/experimental/traffic-portal/package.json 
b/experimental/traffic-portal/package.json
index b9d6f735a8..9dd956b754 100644
--- a/experimental/traffic-portal/package.json
+++ b/experimental/traffic-portal/package.json
@@ -67,6 +67,7 @@
     "chart.js": "^2.9.4",
     "compression-webpack-plugin": "^10.0.0",
     "express": "^4.15.2",
+    "node-forge": "^1.3.1",
     "rxjs": "~6.6.0",
     "trafficops-types": "^4.0.10",
     "tslib": "^2.0.0",
@@ -90,6 +91,7 @@
     "@types/jasminewd2": "~2.0.3",
     "@types/nightwatch": "^2.3.22",
     "@types/node": "^16.18.11",
+    "@types/node-forge": "^1.3.2",
     "@typescript-eslint/eslint-plugin": "^5.59.2",
     "@typescript-eslint/parser": "^5.59.2",
     "axios": "^0.27.2",
diff --git 
a/experimental/traffic-portal/src/app/api/delivery-service.service.spec.ts 
b/experimental/traffic-portal/src/app/api/delivery-service.service.spec.ts
index 6e304681a3..64e96b42dd 100644
--- a/experimental/traffic-portal/src/app/api/delivery-service.service.spec.ts
+++ b/experimental/traffic-portal/src/app/api/delivery-service.service.spec.ts
@@ -14,7 +14,7 @@
  */
 import { HttpClientTestingModule, HttpTestingController } from 
"@angular/common/http/testing";
 import { TestBed } from "@angular/core/testing";
-import { DSStats, DSStatsMetricType } from "trafficops-types";
+import { DSStats, DSStatsMetricType, ResponseDeliveryServiceSSLKey } from 
"trafficops-types";
 
 import { constructDataSetFromResponse, DeliveryServiceService } from 
"./delivery-service.service";
 
@@ -96,6 +96,15 @@ export const testDS = {
        xmlId: "testquest",
 };
 
+/** A dummy DS SSL Key for testing */
+export const testDSSSLKeys: ResponseDeliveryServiceSSLKey = {
+       cdn: testDS.cdnName,
+       certificate: {crt: "", csr: "", key: ""},
+       deliveryservice: testDS.xmlId,
+       expiration: new Date(),
+       version: ""
+};
+
 /**
  * Generates a basic set of DSStats data for use in tests.
  *
@@ -369,7 +378,7 @@ describe("DeliveryServiceService", () => {
                                expect(req.request.method).toBe("GET");
 
                                const metricType = 
req.request.params.get("metricType");
-                               switch(metricType) {
+                               switch (metricType) {
                                        case "tps_total":
                                                req.flush({response: 
totalResponse});
                                                break;
@@ -416,32 +425,34 @@ describe("DeliveryServiceService", () => {
                                
expect(req.request.params.get("endDate")).toBe(now.toISOString());
                                expect(req.request.method).toBe("GET");
 
-                               req.flush({response: {
-                                       series: {
-                                               columns: ["time", "mean"],
-                                               count: 0,
-                                               name: "invalid",
-                                               values: [
-                                                       [
-                                                               twoSecondsAgo,
-                                                               null
+                               req.flush({
+                                       response: {
+                                               series: {
+                                                       columns: ["time", 
"mean"],
+                                                       count: 0,
+                                                       name: "invalid",
+                                                       values: [
+                                                               [
+                                                                       
twoSecondsAgo,
+                                                                       null
+                                                               ],
+                                                               [
+                                                                       now,
+                                                                       0
+                                                               ]
                                                        ],
-                                                       [
-                                                               now,
-                                                               0
-                                                       ]
-                                               ],
-                                       },
-                                       summary: {
-                                               average: 1,
-                                               count: 2,
-                                               fifthPercentile: 3,
-                                               max: 4,
-                                               min: 5,
-                                               ninetyEightPercentile: 6,
-                                               ninetyFifthPercentile: 7
+                                               },
+                                               summary: {
+                                                       average: 1,
+                                                       count: 2,
+                                                       fifthPercentile: 3,
+                                                       max: 4,
+                                                       min: 5,
+                                                       ninetyEightPercentile: 
6,
+                                                       ninetyFifthPercentile: 7
+                                               }
                                        }
-                               }});
+                               });
                        }
 
                        await expectAsync(responseP).toBeRejected();
@@ -570,6 +581,28 @@ describe("DeliveryServiceService", () => {
                await expectAsync(responseP).toBeResolvedTo(response);
        });
 
+       it("gets DS ssl keys", async () => {
+               let resp = service.getSSLKeys(testDS.xmlId);
+               let req = httpTestingController.expectOne(r => r.url ===
+                       
`/api/${service.apiVersion}/deliveryservices/xmlId/${testDS.xmlId}/sslkeys`);
+               expect(req.request.params.keys().length).toBe(1);
+               expect(req.request.params.get("decode")).toBe("true");
+               expect(req.request.method).toBe("GET");
+               req.flush({response: testDSSSLKeys});
+
+               await expectAsync(resp).toBeResolvedTo(testDSSSLKeys);
+
+               resp = service.getSSLKeys(testDS);
+               req = httpTestingController.expectOne(r => r.url ===
+                       
`/api/${service.apiVersion}/deliveryservices/xmlId/${testDS.xmlId}/sslkeys`);
+               expect(req.request.params.keys().length).toBe(1);
+               expect(req.request.params.get("decode")).toBe("true");
+               expect(req.request.method).toBe("GET");
+               req.flush({response: testDSSSLKeys});
+
+               await expectAsync(resp).toBeResolvedTo(testDSSSLKeys);
+       });
+
        afterEach(() => {
                httpTestingController.verify();
        });
diff --git 
a/experimental/traffic-portal/src/app/api/delivery-service.service.ts 
b/experimental/traffic-portal/src/app/api/delivery-service.service.ts
index 8447f58c3d..714dfe9f03 100644
--- a/experimental/traffic-portal/src/app/api/delivery-service.service.ts
+++ b/experimental/traffic-portal/src/app/api/delivery-service.service.ts
@@ -20,7 +20,8 @@ import type {
        RequestDeliveryService,
        ResponseDeliveryService,
        SteeringConfiguration,
-       TypeFromResponse
+       TypeFromResponse,
+       ResponseDeliveryServiceSSLKey
 } from "trafficops-types";
 
 import type {
@@ -441,4 +442,16 @@ export class DeliveryServiceService extends APIService {
                this.deliveryServiceTypes = r;
                return r;
        }
+
+       /**
+        * Gets a Delivery Service's SSL Keys
+        *
+        * @param ds The delivery service xmlid or object
+        * @returns The DS ssl keys
+        */
+       public async getSSLKeys(ds: string | ResponseDeliveryService): 
Promise<ResponseDeliveryServiceSSLKey> {
+               const xmlId = typeof ds === "string" ? ds : ds.xmlId;
+               const path = `deliveryservices/xmlId/${xmlId}/sslkeys`;
+               return this.get<ResponseDeliveryServiceSSLKey>(path, undefined, 
{decode: true}).toPromise();
+       }
 }
diff --git 
a/experimental/traffic-portal/src/app/api/testing/delivery-service.service.ts 
b/experimental/traffic-portal/src/app/api/testing/delivery-service.service.ts
index e561aafa68..cee43c5219 100644
--- 
a/experimental/traffic-portal/src/app/api/testing/delivery-service.service.ts
+++ 
b/experimental/traffic-portal/src/app/api/testing/delivery-service.service.ts
@@ -17,6 +17,7 @@ import type {
        Health,
        RequestDeliveryService,
        ResponseDeliveryService,
+       ResponseDeliveryServiceSSLKey,
        SteeringConfiguration,
        TypeFromResponse
 } from "trafficops-types";
@@ -171,6 +172,13 @@ export class DeliveryServiceService {
                        useInTable: "deliveryservice"
                }
        ];
+       private readonly dsSSLKeys: Array<ResponseDeliveryServiceSSLKey> = [{
+               cdn: "'",
+               certificate: {crt: "", csr: "", key: ""},
+               deliveryservice: "xml",
+               expiration: new Date(),
+               version: ""
+       }];
 
        constructor(
                private readonly cdnService: CDNService,
@@ -536,4 +544,20 @@ export class DeliveryServiceService {
        public async getDSTypes(): Promise<Array<TypeFromResponse>> {
                return this.dsTypes;
        }
+
+       /**
+        * Gets a Delivery Service's SSL Keys
+        *
+        * @param ds The delivery service xmlid or object
+        * @returns The DS ssl keys
+        */
+       public async getSSLKeys(ds: string | ResponseDeliveryService): 
Promise<ResponseDeliveryServiceSSLKey> {
+               const xmlId = typeof ds === "string" ? ds : ds.xmlId;
+               const key = this.dsSSLKeys.find(k => k.deliveryservice === 
xmlId);
+               if(!key) {
+                       throw new Error(`no such Delivery Service: ${xmlId}`);
+               }
+
+               return key;
+       }
 }
diff --git a/experimental/traffic-portal/src/app/app.ui.module.ts 
b/experimental/traffic-portal/src/app/app.ui.module.ts
index 8fdb1a7bbc..d498c26d25 100644
--- a/experimental/traffic-portal/src/app/app.ui.module.ts
+++ b/experimental/traffic-portal/src/app/app.ui.module.ts
@@ -37,6 +37,7 @@ import { MatSidenavModule } from "@angular/material/sidenav";
 import { MatSlideToggleModule } from "@angular/material/slide-toggle";
 import { MatSnackBarModule } from "@angular/material/snack-bar";
 import { MatStepperModule } from "@angular/material/stepper";
+import { MatTabsModule } from "@angular/material/tabs";
 import { MatToolbarModule } from "@angular/material/toolbar";
 import { MatTooltipModule } from "@angular/material/tooltip";
 import { MatTreeModule } from "@angular/material/tree";
@@ -74,6 +75,7 @@ import { AgGridModule } from "ag-grid-angular";
                MatSidenavModule,
                MatSnackBarModule,
                MatStepperModule,
+               MatTabsModule,
                MatToolbarModule,
                MatTooltipModule,
                MatTreeModule,
diff --git 
a/experimental/traffic-portal/src/app/core/certs/cert-author/cert-author.component.html
 
b/experimental/traffic-portal/src/app/core/certs/cert-author/cert-author.component.html
new file mode 100644
index 0000000000..20ded9b2b3
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/certs/cert-author/cert-author.component.html
@@ -0,0 +1,41 @@
+<!--
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<div *ngIf="author" class="content">
+       <mat-form-field>
+               <mat-label>Common Name</mat-label>
+               <input matInput type="text" name="commonName" readonly 
[value]="author.commonName"/>
+       </mat-form-field>
+       <div class="triple">
+               <mat-form-field>
+                       <mat-label>Country Name</mat-label>
+                       <input matInput type="text" name="country" readonly 
[value]="author.countryName ?? ''"/>
+               </mat-form-field>
+               <mat-form-field>
+                       <mat-label>State or Province</mat-label>
+                       <input matInput type="text" name="state" readonly 
[value]="author.stateOrProvince ?? ''"/>
+               </mat-form-field>
+               <mat-form-field>
+                       <mat-label>Locality</mat-label>
+                       <input matInput type="text" name="local" readonly 
[value]="author.localityName ?? ''"/>
+               </mat-form-field>
+       </div>
+       <mat-form-field>
+               <mat-label>Organization</mat-label>
+               <input matInput type="text" name="org" readonly 
[value]="author.orgName ?? ''"/>
+       </mat-form-field>
+       <mat-form-field>
+               <mat-label>Organization Unit</mat-label>
+               <input matInput type="text" name="org" readonly 
[value]="author.orgUnit ?? ''"/>
+       </mat-form-field>
+</div>
diff --git 
a/experimental/traffic-portal/src/app/core/certs/cert-author/cert-author.component.scss
 
b/experimental/traffic-portal/src/app/core/certs/cert-author/cert-author.component.scss
new file mode 100644
index 0000000000..548a34fd32
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/certs/cert-author/cert-author.component.scss
@@ -0,0 +1,22 @@
+/*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*     http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+div.content {
+       display: grid;
+
+       .triple {
+               display: grid;
+               grid-template-columns: 1fr 2fr 2fr;
+               grid-column-gap: 10px;
+       }
+}
diff --git 
a/experimental/traffic-portal/src/app/core/certs/cert-author/cert-author.component.spec.ts
 
b/experimental/traffic-portal/src/app/core/certs/cert-author/cert-author.component.spec.ts
new file mode 100644
index 0000000000..101af9b524
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/certs/cert-author/cert-author.component.spec.ts
@@ -0,0 +1,51 @@
+/*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*     http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+import { HarnessLoader } from "@angular/cdk/testing";
+import { TestbedHarnessEnvironment } from "@angular/cdk/testing/testbed";
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { MatFormFieldHarness } from "@angular/material/form-field/testing";
+import { NoopAnimationsModule } from "@angular/platform-browser/animations";
+
+import { AppUIModule } from "src/app/app.ui.module";
+
+import { CertAuthorComponent } from "./cert-author.component";
+
+describe("CertAuthorComponent", () => {
+       let component: CertAuthorComponent;
+       let fixture: ComponentFixture<CertAuthorComponent>;
+       let loader: HarnessLoader;
+
+       beforeEach(async () => {
+               await TestBed.configureTestingModule({
+                       declarations: [ CertAuthorComponent ],
+                       imports: [ AppUIModule, NoopAnimationsModule ]
+               })
+                       .compileComponents();
+
+               fixture = TestBed.createComponent(CertAuthorComponent);
+               component = fixture.componentInstance;
+               component.author = {
+                       commonName: "name"
+               };
+               fixture.detectChanges();
+               loader = TestbedHarnessEnvironment.documentRootLoader(fixture);
+       });
+
+       it("should create", async () => {
+               expect(component).toBeTruthy();
+               await fixture.whenRenderingDone();
+               const formFields = await 
loader.getAllHarnesses(MatFormFieldHarness);
+               expect(formFields.length).toBe(6);
+       });
+});
diff --git 
a/experimental/traffic-portal/src/app/core/certs/cert-author/cert-author.component.ts
 
b/experimental/traffic-portal/src/app/core/certs/cert-author/cert-author.component.ts
new file mode 100644
index 0000000000..6ad94112e6
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/certs/cert-author/cert-author.component.ts
@@ -0,0 +1,30 @@
+/*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*     http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+
+import { Component, Input } from "@angular/core";
+
+import { type Author } from 
"src/app/core/certs/cert-detail/cert-detail.component";
+
+/**
+ * CertAuthorComponent is the controller used for displaying a cert author.
+ */
+@Component({
+       selector: "tp-cert-author",
+       styleUrls: ["./cert-author.component.scss"],
+       templateUrl: "./cert-author.component.html"
+})
+export class CertAuthorComponent {
+       @Input({required: true}) public author!: Author;
+
+}
diff --git 
a/experimental/traffic-portal/src/app/core/certs/cert-detail/cert-detail.component.html
 
b/experimental/traffic-portal/src/app/core/certs/cert-detail/cert-detail.component.html
new file mode 100644
index 0000000000..d1994158fd
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/certs/cert-detail/cert-detail.component.html
@@ -0,0 +1,68 @@
+<!--
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<div>
+       <div class="mat-elevation-z2 interior-container">
+               <h2>Issuer</h2>
+               <tp-cert-author [author]="issuer"></tp-cert-author>
+       </div>
+       <div class="mat-elevation-z2 interior-container">
+               <h2>Subject</h2>
+               <tp-cert-author [author]="subject"></tp-cert-author>
+       </div>
+       <div class="mat-elevation-z2 interior-container">
+               <h2>Validity</h2>
+               <div class="content">
+                       <div class="double">
+                               <mat-form-field>
+                                       <mat-label>Not Valid Before</mat-label>
+                                       <input matInput type="datetime-local" 
[formControl]="validBeforeFormControl" name="validBefore" readonly/>
+                                       <mat-error 
*ngIf="validBeforeFormControl.hasError('outOfDate')">Not currently 
valid</mat-error>
+                               </mat-form-field>
+                               <mat-form-field>
+                                       <mat-label>Not Valid After</mat-label>
+                                       <input matInput type="datetime-local" 
[formControl]="validAfterFormControl" name="validAfter" readonly/>
+                                       <mat-error 
*ngIf="validAfterFormControl.hasError('outOfDate')">Not currently 
valid</mat-error>
+                               </mat-form-field>
+                       </div>
+               </div>
+       </div>
+       <div class="mat-elevation-z2 interior-container">
+               <h2>Misc</h2>
+               <div class="content">
+                       <div class="double"></div>
+                       <mat-form-field>
+                               <mat-label>Signature Algorithm</mat-label>
+                               <input matInput type="text" name="sigType" 
readonly [value]="oidToName(cert.signatureOid)"/>
+                       </mat-form-field>
+                       <div class="squished">
+                               <mat-form-field>
+                                       <mat-label>Version</mat-label>
+                                       <input matInput type="number" 
name="version" readonly [value]="cert.version.toString()"/>
+                               </mat-form-field>
+                               <mat-form-field>
+                                       <mat-label>Serial Number</mat-label>
+                                       <input matInput type="text" 
name="serialNumber" readonly [value]="cert.serialNumber"/>
+                               </mat-form-field>
+                       </div>
+                       <mat-form-field>
+                               <mat-label>SHA 256</mat-label>
+                               <input matInput type="text" name="sha256" 
readonly [value]="sha256"/>
+                       </mat-form-field>
+                       <mat-form-field>
+                               <mat-label>SHA 1</mat-label>
+                               <input matInput type="text" name="sha1" 
readonly [value]="sha1"/>
+                       </mat-form-field>
+               </div>
+       </div>
+</div>
diff --git 
a/experimental/traffic-portal/src/app/core/certs/cert-detail/cert-detail.component.scss
 
b/experimental/traffic-portal/src/app/core/certs/cert-detail/cert-detail.component.scss
new file mode 100644
index 0000000000..177190a43a
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/certs/cert-detail/cert-detail.component.scss
@@ -0,0 +1,34 @@
+/*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*     http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+
+div.interior-container {
+       padding: .5em 1em;
+       margin-bottom: .5em;
+
+       div.content {
+               display: grid;
+
+               .double {
+                       display: grid;
+                       grid-template-columns: 1fr 1fr;
+                       grid-column-gap: 10px;
+               }
+
+               .squished {
+                       display: grid;
+                       grid-template-columns: 1fr 3fr;
+                       grid-column-gap: 10px;
+               }
+       }
+}
diff --git 
a/experimental/traffic-portal/src/app/core/certs/cert-detail/cert-detail.component.spec.ts
 
b/experimental/traffic-portal/src/app/core/certs/cert-detail/cert-detail.component.spec.ts
new file mode 100644
index 0000000000..7406df6e96
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/certs/cert-detail/cert-detail.component.spec.ts
@@ -0,0 +1,167 @@
+/*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*     http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { pki } from "node-forge";
+
+import { pkiCertToSHA1, pkiCertToSHA256 } from "src/app/core/certs/cert.util";
+
+import { CertDetailComponent } from "./cert-detail.component";
+
+const certPEM = `
+-----BEGIN CERTIFICATE-----
+MIIDeTCCAmECFDWSnKTtkcoRnoTz6ChHqTuvCUPHMA0GCSqGSIb3DQEBCwUAMHkx
+CzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDTzEPMA0GA1UEBwwGRGVudmVyMQ8wDQYD
+VQQKDAZBcGFjaGUxGDAWBgNVBAsMD1RyYWZmaWMgQ29udHJvbDEhMB8GA1UEAwwY
+dHJhZmZpY29wcy5kZXYuY2lhYi50ZXN0MB4XDTIzMDYwNTE0MDY0OFoXDTI0MDYw
+NDE0MDY0OFoweTELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNPMQ8wDQYDVQQHDAZE
+ZW52ZXIxDzANBgNVBAoMBkFwYWNoZTEYMBYGA1UECwwPVHJhZmZpYyBDb250cm9s
+MSEwHwYDVQQDDBh0cmFmZmljb3BzLmRldi5jaWFiLnRlc3QwggEiMA0GCSqGSIb3
+DQEBAQUAA4IBDwAwggEKAoIBAQDKAcQK9fe9w7p7eMnygnlV0rlbUdVr9DEQpKym
+Ul7zGj9/Ta3n0h8xWrmmMi2ZJnIUI4AV7HKaYXiAke1rbEx2jAdvXdjNm/S7RORy
+M0piJc8Si4/EJI1sZU17kZ7howXJvAMCQBqcI+hG93ATlUIOoYuluX7wSNIMw1Np
+lT5bcmVDf5nVQGnrPw22mCGjH5JBxW5i1DjCoNovHfFgNmwP6y8C1jygoMPL+rxl
+sq8fyUE/+qtcEkjUrr4oi9kjTESDqHghrkejKk6NPlPi97SDz2Ffdagoq2aqBhw9
+P86JgplPVHHMWOLXBww0wPAClqY8H7CIt5rgZzoWmoR0DjjNAgMBAAEwDQYJKoZI
+hvcNAQELBQADggEBAMFz7k+egg+hP86ylEAuUfcy/beO3Pf3Fn7oMh5MDENfOzON
+IFqZOQ8pN1zfoAx0rRTzYHcg/AZs2AA4oh+WyEKHDrmICGfsF481b6A0EarZ/cRy
+MF3Vh5rTd8ujWT4V9GP3Hc/I3F5tUKxPWiVEKTVRr6wzjwtXctOnhcbB3FeRtGDY
+CfVBYMSEDJmAyMchfST/GwdG46Ak2TSaMpOf6tL5aMw+xfmDI68JGwG0LNliyEoW
+xOHRCtWd5Q+Sn3rgx4h6nzdZOGHw3HwDbsX/y/dZNc7luUImEWwTyhohnO9XqaBX
+EsdMDJmBaoVum+sR6ch08TsqrTHAfdB3xJF37Wc=
+-----END CERTIFICATE-----`;
+
+describe("CertDetailComponent", () => {
+       let component: CertDetailComponent;
+       let cert: pki.Certificate;
+       let fixture: ComponentFixture<CertDetailComponent>;
+
+       beforeEach(async () => {
+               await TestBed.configureTestingModule({
+                       declarations: [CertDetailComponent]
+               })
+                       .compileComponents();
+
+               cert = pki.certificateFromPem(certPEM);
+               cert.setIssuer([
+                       {
+                               name: "countryName",
+                               shortName: "C",
+                               type: "2.5.4.6",
+                               value: "C",
+                               valueTagClass: 19,
+                       },
+                       {
+                               name: "stateOrProvinceName",
+                               shortName: "ST",
+                               type: "2.5.4.8",
+                               value: "ST",
+                               valueTagClass: 19,
+                       },
+                       {
+                               name: "localityName",
+                               shortName: "L",
+                               type: "2.5.4.7",
+                               value: "L",
+                               valueTagClass: 19,
+                       },
+                       {
+                               name: "organizationName",
+                               shortName: "O",
+                               type: "2.5.4.10",
+                               value: "O",
+                               valueTagClass: 19,
+                       },
+                       {
+                               name: "commonName",
+                               shortName: "CN",
+                               type: "2.5.4.3",
+                               value: "CN",
+                               valueTagClass: 19,
+                       },
+                       {
+                               name: "madeUp",
+                               shortName: "idksomething",
+                               type: "127.0.0.1",
+                               value: "doesntmatter",
+                       }
+               ]);
+               cert.setSubject([
+                       {
+                               name: "countryName",
+                               shortName: "C",
+                               type: "2.5.4.6",
+                               value: "C",
+                               valueTagClass: 19,
+                       },
+                       {
+                               name: "stateOrProvinceName",
+                               shortName: "ST",
+                               type: "2.5.4.8",
+                               value: "ST",
+                               valueTagClass: 19,
+                       },
+                       {
+                               name: "localityName",
+                               shortName: "L",
+                               type: "2.5.4.7",
+                               value: "L",
+                               valueTagClass: 19,
+                       },
+                       {
+                               name: "organizationName",
+                               shortName: "O",
+                               type: "2.5.4.10",
+                               value: "O",
+                               valueTagClass: 19,
+                       },
+                       {
+                               name: "commonName",
+                               shortName: "CN",
+                               type: "2.5.4.3",
+                               value: "CN",
+                               valueTagClass: 19,
+                       }
+
+               ]);
+
+               fixture = TestBed.createComponent(CertDetailComponent);
+               component = fixture.componentInstance;
+               component.cert = cert;
+               fixture.detectChanges();
+       });
+
+       it("should create", () => {
+               expect(component).toBeTruthy();
+       });
+
+       it("Fields calculated correctly", async () => {
+               component.ngOnChanges();
+               expect(component.issuer).toEqual({
+                       commonName: "CN",
+                       countryName: "C",
+                       localityName: "L",
+                       orgName: "O",
+                       stateOrProvince: "ST",
+               });
+               expect(component.subject).toEqual({
+                       commonName: "CN",
+                       countryName: "C",
+                       localityName: "L",
+                       orgName: "O",
+                       stateOrProvince: "ST",
+               });
+               expect(component.sha1).toBe(pkiCertToSHA1(component.cert));
+               expect(component.sha256).toBe(pkiCertToSHA256(component.cert));
+       });
+});
diff --git 
a/experimental/traffic-portal/src/app/core/certs/cert-detail/cert-detail.component.ts
 
b/experimental/traffic-portal/src/app/core/certs/cert-detail/cert-detail.component.ts
new file mode 100644
index 0000000000..8f7a6c748c
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/certs/cert-detail/cert-detail.component.ts
@@ -0,0 +1,133 @@
+/*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*     http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+import { Component, Input, OnChanges } from "@angular/core";
+import { AbstractControl, FormControl, ValidationErrors, ValidatorFn } from 
"@angular/forms";
+import { pki, Hex } from "node-forge";
+
+import { oidToName, pkiCertToSHA1, pkiCertToSHA256 } from 
"src/app/core/certs/cert.util";
+
+/**
+ * Author contains the information about an author from a cert issuer/subject
+ */
+export interface Author {
+       countryName?: string | undefined;
+       stateOrProvince?: string | undefined;
+       localityName?: string | undefined;
+       orgName?: string | undefined;
+       orgUnit?: string | undefined;
+       commonName: string;
+}
+
+/**
+ * Angular validator that checks if the value is either before or after the 
given time
+ *
+ * @param before If this is only valid before
+ * @param now The time to compare against
+ * @returns Validator Function
+ */
+function createDateValidator(before: boolean, now: Date): ValidatorFn {
+       return (control: AbstractControl): ValidationErrors | null => {
+               const value = control.value;
+               if (!value) {
+                       return null;
+               }
+
+               let valid = false;
+               const d = new Date(value);
+               if (before) {
+                       valid = now >= d;
+               } else {
+                       valid = now <= d;
+               }
+
+               return valid ? null : {outOfDate: true};
+       };
+}
+
+/**
+ * Controller for the Cert Detail component
+ */
+@Component({
+       selector: "tp-cert-detail",
+       styleUrls: ["./cert-detail.component.scss"],
+       templateUrl: "./cert-detail.component.html"
+})
+export class CertDetailComponent implements OnChanges {
+       @Input({required: true}) public cert!: pki.Certificate;
+
+       public issuer: Author = {commonName: ""};
+       public subject: Author = {commonName: ""};
+       public now: Date = new Date();
+       public validAfterFormControl = new FormControl<string>("", 
[createDateValidator(false, this.now)]);
+       public validBeforeFormControl = new FormControl<string>("", 
[createDateValidator(true, this.now)]);
+
+       public sha1: Hex = "";
+       public sha256: Hex = "";
+
+       /**
+        * processAttributes converts attributes into an author
+        *
+        * @param attrs The attributes to process
+        * @returns The resultant author
+        */
+       public processAttributes(attrs: pki.CertificateField[]): Author {
+               const a: Author = {commonName: ""};
+               for (const attr of attrs) {
+                       if (attr.name && attr.value) {
+                               if (typeof attr.value !== "string") {
+                                       console.warn(`Unknown attribute value 
${attr.value}`);
+                                       continue;
+                               }
+                               switch (attr.name) {
+                                       case "commonName":
+                                               a.commonName = attr.value;
+                                               break;
+                                       case "countryName":
+                                               a.countryName = attr.value;
+                                               break;
+                                       case "stateOrProvinceName":
+                                               a.stateOrProvince = attr.value;
+                                               break;
+                                       case "localityName":
+                                               a.localityName = attr.value;
+                                               break;
+                                       case "organizationName":
+                                               a.orgName = attr.value;
+                                               break;
+                                       case "organizationUnitName":
+                                               a.orgUnit = attr.value;
+                                               break;
+                               }
+                       }
+               }
+               return a;
+       }
+
+       /**
+        * Calculates certificate details
+        */
+       public ngOnChanges(): void {
+               this.sha1 = pkiCertToSHA1(this.cert);
+               this.sha256 = pkiCertToSHA256(this.cert);
+               this.issuer = 
this.processAttributes(this.cert.issuer.attributes);
+               this.subject = 
this.processAttributes(this.cert.subject.attributes);
+
+               
this.validBeforeFormControl.setValue(this.cert.validity.notBefore.toISOString().slice(0,
 16));
+               
this.validAfterFormControl.setValue(this.cert.validity.notAfter.toISOString().slice(0,
 16));
+               this.validAfterFormControl.markAsTouched();
+               this.validBeforeFormControl.markAsTouched();
+       }
+
+       protected readonly oidToName = oidToName;
+}
diff --git 
a/experimental/traffic-portal/src/app/core/certs/cert-viewer/cert-viewer.component.html
 
b/experimental/traffic-portal/src/app/core/certs/cert-viewer/cert-viewer.component.html
new file mode 100644
index 0000000000..c831b29220
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/certs/cert-viewer/cert-viewer.component.html
@@ -0,0 +1,77 @@
+<!--
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<mat-card appearance="outlined" class="page-content">
+       <tp-loading *ngIf="dsCert === undefined"></tp-loading>
+       <mat-card-content *ngIf="dsCert !== undefined">
+               <mat-tab-group #matTab>
+                       <mat-tab label="Input Certificate" *ngIf="!dsCert">
+                                       <form ngNativeValidate 
(submit)="process(true)" class="tab-content">
+                                               <mat-form-field>
+                                                       <mat-label>Certificate 
Chain</mat-label>
+                                                       <textarea matInput 
name="custom" [(ngModel)]="inputCert" cdkAutosizeMaxRows="15" 
cdkTextareaAutosize required></textarea>
+                                               </mat-form-field>
+                                               <button mat-raised-button 
type="submit">Submit</button>
+                                       </form>
+                       </mat-tab>
+                       <mat-tab label="Certificate Details" 
[disabled]="certChain.length === 0">
+                               <div class="tab-content">
+                                       <mat-form-field>
+                                               <mat-label>Detected 
Order</mat-label>
+                                               <input matInput readonly 
name="order" [(ngModel)]="certOrder"/>
+                                       </mat-form-field>
+                                       <mat-accordion>
+                                               <mat-expansion-panel 
*ngFor="let c of certChain; let i = index;">
+                                                       
<mat-expansion-panel-header>{{c.type}}</mat-expansion-panel-header>
+                                                       <tp-cert-detail 
*ngIf="!c.parseError" [cert]="c"></tp-cert-detail>
+                                                       <div 
*ngIf="c.parseError">Unable to parse this certificate!</div>
+                                               </mat-expansion-panel>
+                                       </mat-accordion>
+                               </div>
+                       </mat-tab>
+                       <mat-tab label="TO Cert Info" *ngIf="dsCert">
+                               <div class="tab-content">
+                                       <mat-form-field>
+                                               <mat-label>Version</mat-label>
+                                               <input type="text" matInput 
name="version" [(ngModel)]="cert.version" />
+                                       </mat-form-field>
+                                       <mat-form-field>
+                                               <mat-label>Common 
Name</mat-label>
+                                               <input type="text" matInput 
name="commonName" [(ngModel)]="cert.hostname" />
+                                       </mat-form-field>
+                                       <mat-form-field>
+                                               
<mat-label>Expiration</mat-label>
+                                               <input type="text" matInput 
name="expiration" [(ngModel)]="cert.expiration" />
+                                       </mat-form-field>
+                                       <mat-form-field>
+                                               <mat-label>SANs</mat-label>
+                                               <input type="text" matInput 
name="sans" [(ngModel)]="cert.sans" />
+                                       </mat-form-field>
+                                       <mat-form-field>
+                                               <mat-label>Private 
Key</mat-label>
+                                               <textarea matInput 
class="private-text" name="privateKey" [(ngModel)]="cert.certificate.key" 
cdkAutosizeMaxRows="15" cdkTextareaAutosize></textarea>
+                                       </mat-form-field>
+                                       <mat-form-field>
+                                               <mat-label>Certificate Signing 
Request</mat-label>
+                                               <textarea matInput name="csr" 
[(ngModel)]="cert.certificate.csr" cdkAutosizeMaxRows="15" 
cdkTextareaAutosize></textarea>
+                                       </mat-form-field>
+                                       <mat-form-field>
+                                               
<mat-label>Certificate</mat-label>
+                                               <textarea matInput name="crt" 
[(ngModel)]="cert.certificate.crt" cdkAutosizeMaxRows="15" 
cdkTextareaAutosize></textarea>
+                                       </mat-form-field>
+                               </div>
+                       </mat-tab>
+               </mat-tab-group>
+       </mat-card-content>
+</mat-card>
diff --git 
a/experimental/traffic-portal/src/app/core/certs/cert-viewer/cert-viewer.component.scss
 
b/experimental/traffic-portal/src/app/core/certs/cert-viewer/cert-viewer.component.scss
new file mode 100644
index 0000000000..2042ec0968
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/certs/cert-viewer/cert-viewer.component.scss
@@ -0,0 +1,35 @@
+/*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*     http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+
+.tab-content {
+       margin-top: 5px;
+       display: grid;
+
+       &:is(form), .mat-accordion {
+               width: 98%;
+               margin: auto auto 15px;
+       }
+}
+
+.private-text {
+       &:not(:focus) {
+               color: transparent;
+               text-shadow: 0 0 8px rgba(0, 0, 0, 0.5);
+       }
+
+       &:focus {
+               color: inherit;
+               text-shadow: none;
+       }
+}
diff --git 
a/experimental/traffic-portal/src/app/core/certs/cert-viewer/cert-viewer.component.spec.ts
 
b/experimental/traffic-portal/src/app/core/certs/cert-viewer/cert-viewer.component.spec.ts
new file mode 100644
index 0000000000..398a26b81b
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/certs/cert-viewer/cert-viewer.component.spec.ts
@@ -0,0 +1,161 @@
+/*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*     http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { NoopAnimationsModule } from "@angular/platform-browser/animations";
+import { RouterTestingModule } from "@angular/router/testing";
+import * as forge from "node-forge";
+
+import { APITestingModule } from "src/app/api/testing";
+import { AppUIModule } from "src/app/app.ui.module";
+
+import { CertViewerComponent } from "./cert-viewer.component";
+
+/**
+ * Creates any type of cert in a chain
+ *
+ * @param validStart Not valid before time
+ * @param validEnd Not valid after time
+ * @param issuer The issuer attributes
+ * @param subject The subject attributes
+ * @param extensions The cert extensions
+ * @returns The cert
+ */
+export function createCert(validStart: Date, validEnd: Date, issuer: 
forge.pki.CertificateField[],
+       subject: forge.pki.CertificateField[], extensions: unknown[]): 
forge.pki.Certificate {
+       const kp = forge.pki.rsa.generateKeyPair(2048);
+       const cert = forge.pki.createCertificate();
+       cert.publicKey = kp.publicKey;
+       cert.privateKey = kp.privateKey;
+       cert.validity.notBefore = validStart;
+       cert.validity.notAfter = validEnd;
+       cert.setSubject(subject);
+       cert.setIssuer(issuer);
+       cert.setExtensions(extensions);
+       cert.sign(kp.privateKey, forge.md.sha256.create());
+       return cert;
+}
+
+/**
+ * Creates a certificate and signs it using a CA
+ *
+ * @param validStart Not valid before time
+ * @param validEnd Not valid after time
+ * @param subject Cert subject
+ * @param ca The CA that issued this cert
+ * @returns The cert
+ */
+export function createCertAndSign(validStart: Date, validEnd: Date, subject: 
forge.pki.CertificateField[],
+       ca: forge.pki.Certificate): forge.pki.Certificate {
+       const cert = createCert(validStart, validEnd, ca.issuer.attributes, 
subject, []);
+       cert.sign(ca.privateKey, forge.md.sha256.create());
+       return cert;
+}
+
+/**
+ * Creates a Certificate Authority
+ *
+ * @param validStart Not valid before time
+ * @param validEnd Not valid after time
+ * @param attrs Both subject & issuer attributes
+ * @returns The CA
+ */
+export function createCA(validStart: Date, validEnd: Date, attrs: 
forge.pki.CertificateField[]): forge.pki.Certificate {
+       return createCert(validStart, validEnd, attrs, attrs,
+               [{
+                       cA: true,
+                       name: "basicConstraints"
+               }, {
+                       keyCertSign: true,
+                       name: "keyUsage"
+               }, {
+                       name: "nsCertType",
+                       server: true
+               }]);
+}
+
+describe("CertViewerComponent", () => {
+       let component: CertViewerComponent;
+       let fixture: ComponentFixture<CertViewerComponent>;
+
+       beforeEach(async () => {
+               await TestBed.configureTestingModule({
+                       declarations: [CertViewerComponent],
+                       imports: [APITestingModule, AppUIModule, 
NoopAnimationsModule, RouterTestingModule]
+               })
+                       .compileComponents();
+
+               fixture = TestBed.createComponent(CertViewerComponent);
+               component = fixture.componentInstance;
+               fixture.detectChanges();
+       });
+
+       it("should create", () => {
+               expect(component).toBeTruthy();
+               expect(component.dsCert).toBe(false);
+       });
+
+       it("new cert", () => {
+               component.inputCert = "invalid";
+               component.process(true);
+               expect(component.certChain.length).toBe(1);
+               expect(component.certChain[0].parseError).toBeTrue();
+               expect(component.certChain[0].type).toBe("Error");
+               expect(component.certOrder).toBe("Unknown");
+       });
+
+       it("good cert", () => {
+               const today = new Date();
+               component.inputCert = 
forge.pki.certificateToPem(createCert(today, today, [{
+                       name: "commonName",
+                       value: "Test"
+               }], [{
+                       name: "commonName",
+                       value: "Test"
+               }], []));
+               component.process(true);
+               expect(component.certChain.length).toBe(1);
+               expect(component.certChain[0].parseError).toBeFalsy();
+               expect(component.certChain[0].type).toBe("Root");
+               expect(component.certOrder).toBe("Single");
+
+       });
+
+       it("root chain", () => {
+               const today = new Date();
+               const ca = createCA(today, today, [{
+                       name: "commonName",
+                       value: "Test"
+               }]);
+               const cert = createCertAndSign(today, today, [{
+                       name: "commonName",
+                       value: "Test2"
+               }], ca);
+
+               component.inputCert = 
`${forge.pki.certificateToPem(ca)}${forge.pki.certificateToPem(cert)}`;
+               component.process(true);
+               expect(component.certChain.length).toBe(2);
+               expect(component.certChain.some(c => c.parseError)).toBeFalse();
+               expect(component.certChain[0].type).toBe("Root");
+               expect(component.certChain[1].type).toBe("Client");
+               expect(component.certOrder).toBe("Root -> Client");
+
+               component.inputCert = 
`${forge.pki.certificateToPem(cert)}${forge.pki.certificateToPem(ca)}`;
+               component.process(true);
+               expect(component.certChain.length).toBe(2);
+               expect(component.certChain.some(c => c.parseError)).toBeFalse();
+               expect(component.certChain[0].type).toBe("Root");
+               expect(component.certChain[1].type).toBe("Client");
+               expect(component.certOrder).toBe("Client -> Root");
+       });
+});
diff --git 
a/experimental/traffic-portal/src/app/core/certs/cert-viewer/cert-viewer.component.ts
 
b/experimental/traffic-portal/src/app/core/certs/cert-viewer/cert-viewer.component.ts
new file mode 100644
index 0000000000..0276e73f7c
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/certs/cert-viewer/cert-viewer.component.ts
@@ -0,0 +1,193 @@
+/*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*     http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+import { Component, OnInit, ViewChild } from "@angular/core";
+import { FormControl } from "@angular/forms";
+import { MatTabGroup } from "@angular/material/tabs";
+import { ActivatedRoute, Router } from "@angular/router";
+import { pki } from "node-forge";
+import { type ResponseDeliveryServiceSSLKey } from "trafficops-types";
+
+import { DeliveryServiceService } from "src/app/api";
+
+/**
+ * What type of cert is it
+ */
+type CertType = "Root" | "Client" | "Intermediate" | "Unknown" | "Error";
+
+/**
+ * Detected order of the cert chain
+ */
+type CertOrder = "Client -> Root" | "Root -> Client" | "Unknown" | "Single";
+
+/**
+ * Wrapper around Certificate that contains additional fields
+ */
+export interface AugmentedCertificate extends pki.Certificate {
+       type: CertType;
+       parseError: boolean;
+}
+
+export const NULL_CERT = pki.createCertificate() as AugmentedCertificate;
+NULL_CERT.type = "Error";
+NULL_CERT.parseError = true;
+
+/**
+ * Controller for the Cert Viewer component.
+ */
+@Component({
+       selector: "tp-cert-viewer",
+       styleUrls: ["./cert-viewer.component.scss"],
+       templateUrl: "./cert-viewer.component.html"
+})
+export class CertViewerComponent implements OnInit {
+       public cert!: ResponseDeliveryServiceSSLKey;
+       public inputCert = "";
+       public dsCert = false;
+
+       public certChain: Array<AugmentedCertificate> = [];
+       public certOrder: CertOrder | undefined;
+       public privateKeyFormControl = new FormControl("");
+
+       @ViewChild("matTab") public matTab!: MatTabGroup;
+       constructor(
+               private readonly route: ActivatedRoute,
+               private readonly dsAPI: DeliveryServiceService,
+               private readonly router: Router) {
+       }
+
+       /**
+        * newCert creates a cert from an input string.
+        *
+        * @param input The text to read as a cert
+        * @private
+        * @returns Resultant Cert
+        */
+       private newCert(input: string): AugmentedCertificate {
+               try {
+                       return pki.certificateFromPem(input) as 
AugmentedCertificate;
+               } catch (e) {
+                       console.error(`ran into issue creating certificate from 
input ${input}`, e);
+                       return NULL_CERT;
+               }
+       }
+
+       /**
+        * process takes the Cert Chain text input and parses it.
+        *
+        * @param uploaded if the certificate was uploaded by the client.
+        */
+       public process(uploaded: boolean = false): void {
+               this.inputCert = this.inputCert.replace(/\r\n/g, "\n");
+               const parts = this.inputCert.split("-\n-");
+               const certs = new Array<AugmentedCertificate>(parts.length);
+               for(let i = 1; i < parts.length; ++i) {
+                       parts[i-1] += "-";
+                       parts[i] = `-${parts[i]}`;
+                       certs[i-1] = this.newCert(parts[i - 1]);
+               }
+               certs[certs.length-1] = this.newCert(parts[parts.length - 1]);
+               const assignType = (c: AugmentedCertificate, i: number): void 
=> {
+                       if(c.parseError) {
+                               return;
+                       }
+                       if (i === 0) {
+                               c.type = "Root";
+                       } else if (i === certs.length - 1) {
+                               c.type = "Client";
+                       } else {
+                               c.type = "Intermediate";
+                       }
+               };
+               const chain = this.reOrderRootFirst(certs);
+               chain.forEach(assignType);
+               this.certChain = chain;
+
+               if(this.matTab && uploaded) {
+                       this.matTab.selectedIndex = 1;
+               }
+       }
+
+       /**
+        * reOrderRootFirst sorts a cert chain with the root being first if 
possible.
+        *
+        * @param certs The list of certs to reorder
+        * @returns The processed certs
+        */
+       public reOrderRootFirst(certs: Array<AugmentedCertificate>): 
Array<AugmentedCertificate> {
+               let rootFirst = false;
+               let invalid = false;
+               for(let i = 1; i < certs.length; ++i){
+                       const first = certs[i-1];
+                       const next = certs[i];
+                       if(first.parseError) {
+                               invalid = true;
+                               continue;
+                       } else if (next.parseError) {
+                               invalid = true;
+                               continue;
+                       }
+                       if (first.issued(next)) {
+                               rootFirst = true;
+                       } else if (next.issued(first)) {
+                               rootFirst = false;
+                       } else {
+                               invalid = true;
+                               console.error(`Cert chain is invalid, cert 
${i-1} and ${i} are not related`);
+                       }
+               }
+
+               if (certs.length === 1) {
+                       if (certs[0].parseError) {
+                               invalid = true;
+                       } else {
+                               this.certOrder = "Single";
+                               return certs;
+                       }
+               }
+               if (invalid) {
+                       this.certOrder = "Unknown";
+                       return certs;
+               }
+
+               if(rootFirst) {
+                       this.certOrder = "Root -> Client";
+                       return certs;
+               }
+               this.certOrder = "Client -> Root";
+               certs = certs.reverse();
+               return certs;
+       }
+
+       /**
+        * Checks if we are a DS cert or any user provided cert.
+        */
+       public async ngOnInit(): Promise<void> {
+               const ID = this.route.snapshot.paramMap.get("xmlId");
+               if (ID === null) {
+                       this.dsCert = false;
+                       return;
+               }
+               try {
+                       this.cert = await this.dsAPI.getSSLKeys(ID);
+               } catch {
+                       await this.router.navigate(["/core/certs/ssl/"]);
+                       return;
+               }
+               this.dsCert = true;
+               this.inputCert = this.cert.certificate.crt;
+               this.privateKeyFormControl.setValue(this.cert.certificate.key);
+               this.process();
+       }
+
+}
diff --git a/experimental/traffic-portal/src/app/core/certs/cert.util.spec.ts 
b/experimental/traffic-portal/src/app/core/certs/cert.util.spec.ts
new file mode 100644
index 0000000000..a8fcc24bf8
--- /dev/null
+++ b/experimental/traffic-portal/src/app/core/certs/cert.util.spec.ts
@@ -0,0 +1,55 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import * as forge from "node-forge";
+
+import { oidToName, pkiCertToSHA1, pkiCertToSHA256 } from 
"src/app/core/certs/cert.util";
+
+const certPEM = `
+-----BEGIN CERTIFICATE-----
+MIIDeTCCAmECFDWSnKTtkcoRnoTz6ChHqTuvCUPHMA0GCSqGSIb3DQEBCwUAMHkx
+CzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDTzEPMA0GA1UEBwwGRGVudmVyMQ8wDQYD
+VQQKDAZBcGFjaGUxGDAWBgNVBAsMD1RyYWZmaWMgQ29udHJvbDEhMB8GA1UEAwwY
+dHJhZmZpY29wcy5kZXYuY2lhYi50ZXN0MB4XDTIzMDYwNTE0MDY0OFoXDTI0MDYw
+NDE0MDY0OFoweTELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNPMQ8wDQYDVQQHDAZE
+ZW52ZXIxDzANBgNVBAoMBkFwYWNoZTEYMBYGA1UECwwPVHJhZmZpYyBDb250cm9s
+MSEwHwYDVQQDDBh0cmFmZmljb3BzLmRldi5jaWFiLnRlc3QwggEiMA0GCSqGSIb3
+DQEBAQUAA4IBDwAwggEKAoIBAQDKAcQK9fe9w7p7eMnygnlV0rlbUdVr9DEQpKym
+Ul7zGj9/Ta3n0h8xWrmmMi2ZJnIUI4AV7HKaYXiAke1rbEx2jAdvXdjNm/S7RORy
+M0piJc8Si4/EJI1sZU17kZ7howXJvAMCQBqcI+hG93ATlUIOoYuluX7wSNIMw1Np
+lT5bcmVDf5nVQGnrPw22mCGjH5JBxW5i1DjCoNovHfFgNmwP6y8C1jygoMPL+rxl
+sq8fyUE/+qtcEkjUrr4oi9kjTESDqHghrkejKk6NPlPi97SDz2Ffdagoq2aqBhw9
+P86JgplPVHHMWOLXBww0wPAClqY8H7CIt5rgZzoWmoR0DjjNAgMBAAEwDQYJKoZI
+hvcNAQELBQADggEBAMFz7k+egg+hP86ylEAuUfcy/beO3Pf3Fn7oMh5MDENfOzON
+IFqZOQ8pN1zfoAx0rRTzYHcg/AZs2AA4oh+WyEKHDrmICGfsF481b6A0EarZ/cRy
+MF3Vh5rTd8ujWT4V9GP3Hc/I3F5tUKxPWiVEKTVRr6wzjwtXctOnhcbB3FeRtGDY
+CfVBYMSEDJmAyMchfST/GwdG46Ak2TSaMpOf6tL5aMw+xfmDI68JGwG0LNliyEoW
+xOHRCtWd5Q+Sn3rgx4h6nzdZOGHw3HwDbsX/y/dZNc7luUImEWwTyhohnO9XqaBX
+EsdMDJmBaoVum+sR6ch08TsqrTHAfdB3xJF37Wc=
+-----END CERTIFICATE-----`;
+describe("Cert Utilities Test", () => {
+       it("oid to name", () => {
+               expect(oidToName("thisisnotanoid")).toBe("");
+               
expect(oidToName("1.2.840.113549.1.1.12")).toBe("sha384WithRSAEncryption");
+       });
+
+       it("sha cert digest", () => {
+               const cert = forge.pki.certificateFromPem(certPEM);
+               
expect(pkiCertToSHA1(cert)).toBe("4afd0f20041efe4474ef44a125d0715cacc269e5");
+               
expect(pkiCertToSHA256(cert)).toBe("531d5eca87cc038077c6403615e0df646c0eac604a6292b79db1f8a014a7fdf8");
+
+               expect(() => 
pkiCertToSHA1(forge.pki.createCertificate())).toThrowError();
+               expect(() => 
pkiCertToSHA256(forge.pki.createCertificate())).toThrowError();
+       });
+});
diff --git a/experimental/traffic-portal/src/app/core/certs/cert.util.ts 
b/experimental/traffic-portal/src/app/core/certs/cert.util.ts
new file mode 100644
index 0000000000..0f6a3e610a
--- /dev/null
+++ b/experimental/traffic-portal/src/app/core/certs/cert.util.ts
@@ -0,0 +1,51 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { pki, md, asn1 } from "node-forge";
+
+/**
+ * Converts a given oid to it's human-readable name
+ *
+ * @param oid The oid to translate
+ * @returns The human-readable oid
+ */
+export function oidToName(oid: string): string {
+       if (oid in pki.oids) {
+               return pki.oids[oid];
+       }
+       return "";
+}
+
+/**
+ * Calculate the SHA-1 of a given certificate
+ *
+ * @param cert The certificate to hash
+ * @returns SHA-1 of the cert
+ */
+export function pkiCertToSHA1(cert: pki.Certificate): string {
+       const md1 = md.sha1.create();
+       md1.update(asn1.toDer(pki.certificateToAsn1(cert)).getBytes());
+       return md1.digest().toHex();
+}
+
+/**
+ * Calculate the SHA-256 of a given certificate
+ *
+ * @param cert The certificate to hash
+ * @returns SHA-256 of the cert
+ */
+export function pkiCertToSHA256(cert: pki.Certificate): string {
+       const md256 = md.sha256.create();
+       md256.update(asn1.toDer(pki.certificateToAsn1(cert)).getBytes());
+       return md256.digest().toHex();
+}
diff --git a/experimental/traffic-portal/src/app/core/certs/certs.module.ts 
b/experimental/traffic-portal/src/app/core/certs/certs.module.ts
new file mode 100644
index 0000000000..0a62b029f9
--- /dev/null
+++ b/experimental/traffic-portal/src/app/core/certs/certs.module.ts
@@ -0,0 +1,49 @@
+/*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*     http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+import { CommonModule } from "@angular/common";
+import { NgModule } from "@angular/core";
+import { RouterModule, Routes } from "@angular/router";
+
+import { AppUIModule } from "src/app/app.ui.module";
+import { CertViewerComponent } from 
"src/app/core/certs/cert-viewer/cert-viewer.component";
+import { SharedModule } from "src/app/shared/shared.module";
+
+import { CertAuthorComponent } from "./cert-author/cert-author.component";
+import { CertDetailComponent } from "./cert-detail/cert-detail.component";
+
+export const ROUTES: Routes = [
+       {component: CertViewerComponent, path: "ssl/:xmlId"},
+       {component: CertViewerComponent, path: "ssl"}
+];
+
+/**
+ * Declares the module for SSL certificates. Is seperated since `node-forge` 
which provides
+ * SSL functions is quite large.
+ */
+@NgModule({
+       declarations: [
+               CertViewerComponent,
+               CertDetailComponent,
+               CertAuthorComponent,
+       ],
+       exports: [],
+       imports: [
+               CommonModule,
+               AppUIModule,
+               SharedModule,
+               RouterModule.forChild(ROUTES)
+       ]
+})
+export class CertsModule {
+}
diff --git a/experimental/traffic-portal/src/app/core/core.module.ts 
b/experimental/traffic-portal/src/app/core/core.module.ts
index fa0757870c..db9257a8c5 100644
--- a/experimental/traffic-portal/src/app/core/core.module.ts
+++ b/experimental/traffic-portal/src/app/core/core.module.ts
@@ -17,9 +17,11 @@
  * limitations under the License.
  */
 import { CommonModule } from "@angular/common";
-import { NgModule } from "@angular/core";
+import { NgModule, Type } from "@angular/core";
 import { RouterModule, type Routes } from "@angular/router";
 
+import { type CertsModule } from "src/app/core/certs/certs.module";
+
 import { AppUIModule } from "../app.ui.module";
 import { AuthenticatedGuard } from "../guards/authenticated-guard.service";
 import { SharedModule } from "../shared/shared.module";
@@ -73,6 +75,14 @@ import { UserRegistrationDialogComponent } from 
"./users/user-registration-dialo
 import { UsersComponent } from "./users/users.component";
 
 export const ROUTES: Routes = [
+       {
+               children: [{
+                       loadChildren: async (): Promise<Type<CertsModule>> => 
import("./certs/certs.module")
+                               .then(mod => mod.CertsModule),
+                       path: ""
+               }],
+               path: "certs"
+       },
        { component: DashboardComponent, path: "" },
        { component: AsnDetailComponent, path: "asns/:id"},
        { component: AsnsTableComponent, path: "asns" },
diff --git 
a/experimental/traffic-portal/src/app/core/statuses/statuses-table/statuses-table.component.html
 
b/experimental/traffic-portal/src/app/core/statuses/statuses-table/statuses-table.component.html
index b038758362..ff8f9140f6 100644
--- 
a/experimental/traffic-portal/src/app/core/statuses/statuses-table/statuses-table.component.html
+++ 
b/experimental/traffic-portal/src/app/core/statuses/statuses-table/statuses-table.component.html
@@ -11,7 +11,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express 
or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<mat-card class="page-content" appearance="outlined">
+<mat-card class="page-content">
        <div class="search-container">
                <input type="search" name="fuzzControl" aria-label="Search 
Statuses" inputmode="search" role="search" accesskey="/"
                                         placeholder="Fuzzy Search" 
[formControl]="fuzzControl" (input)="updateURL()"/>
diff --git 
a/experimental/traffic-portal/src/app/core/statuses/statuses-table/statuses-table.component.ts
 
b/experimental/traffic-portal/src/app/core/statuses/statuses-table/statuses-table.component.ts
index e97879d215..b8d7c179a5 100644
--- 
a/experimental/traffic-portal/src/app/core/statuses/statuses-table/statuses-table.component.ts
+++ 
b/experimental/traffic-portal/src/app/core/statuses/statuses-table/statuses-table.component.ts
@@ -92,12 +92,6 @@ export class StatusesTableComponent implements OnInit {
        /** Form controller for the user search input. */
        public fuzzControl = new FormControl<string>("", {nonNullable: true});
 
-       /**
-        * Constructs the component with its required injections.
-        *
-        * @param api The Servers API which is used to provide row data.
-        * @param navSvc Manages the header
-        */
        constructor(
                private readonly dialog: MatDialog,
                private readonly route: ActivatedRoute,
diff --git 
a/experimental/traffic-portal/src/app/shared/navigation/navigation.service.ts 
b/experimental/traffic-portal/src/app/shared/navigation/navigation.service.ts
index 3130756f97..fc762b7069 100644
--- 
a/experimental/traffic-portal/src/app/shared/navigation/navigation.service.ts
+++ 
b/experimental/traffic-portal/src/app/shared/navigation/navigation.service.ts
@@ -230,6 +230,10 @@ export class NavigationService {
                                        href: "/core/iso-gen",
                                        name: "Generate System ISO"
                                },
+                               {
+                                       href: "/core/certs/ssl",
+                                       name: "Inspect Certificate"
+                               },
                                {
                                        href: `${this.tpv1Url}/jobs`,
                                        name: "Invalidate Content"

Reply via email to