Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enhance PKCS#12 experience and Android compatibility #10

Merged
merged 8 commits into from
Mar 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/supervisor/src/api/pki.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,16 @@ export class PkiController {
)
}

@Get("/ca/pem")
@EncodeResponseWith(t.string)
async getCertificateAuthorityPem(): Promise<string> {
const ca = await this.ca.get()
if (!ca) {
throw new NotFoundException(null, "CA certificate not found")
}
return ca.exportCertificateAsPem()
}

@Delete("/ca/:serial")
@EncodeResponseWith(t.undefined)
revokeCertificateAuthority(@Param("serial") unknownSerial: unknown): Promise<void> {
Expand Down
7 changes: 1 addition & 6 deletions packages/supervisor/src/pki/pkijs/cryptoEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,6 @@ function encryptWithPbeSha1(
return encrypted
}

function pkcs7Pad(message: Buffer, blockSize: number): Buffer {
const size = blockSize - (message.length % blockSize)
return Buffer.concat([message, Buffer.alloc(size, size)])
}

/**
* A shim for pkijs.CryptoEngine that implements the legacy pbeWithSHA1And3-KeyTripleDES-CBC.
*/
Expand Down Expand Up @@ -77,7 +72,7 @@ export class CryptoEngineShim extends pkijs.CryptoEngine {
private encryptEncryptedContentInfoWithPbe1(
parameters: pkijs.CryptoEngineEncryptParams,
): pkijs.EncryptedContentInfo {
const contentToEncrypt = pkcs7Pad(Buffer.from(parameters.contentToEncrypt), 8)
const contentToEncrypt = Buffer.from(parameters.contentToEncrypt)
const {
contentEncryptionAlgorithm: { name: algorithm },
contentType,
Expand Down
21 changes: 20 additions & 1 deletion packages/supervisor/src/pki/pkijs/pkcs12.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import * as asn1js from "asn1js"
import * as pkijs from "pkijs"

import { OID_PKCS12_BagId_CertBag, OID_PKCS12_BagId_PKCS8ShroudedKeyBag, OID_PKCS9_LocalKeyId } from "../consts"
import {
OID_PKCS12_BagId_CertBag,
OID_PKCS12_BagId_PKCS8ShroudedKeyBag,
OID_PKCS9_FriendlyName,
OID_PKCS9_LocalKeyId,
} from "../consts"

function getSafeContentEncryptionParams(algorithm: "DES-EDE3-CBC" | "RC2-40-CBC", password: ArrayBuffer) {
const params = {
Expand Down Expand Up @@ -41,6 +46,10 @@ export async function exportAsPkcs12(
}),
],
}),
new pkijs.Attribute({
type: OID_PKCS9_FriendlyName,
values: [new asn1js.BmpString({ value: "certificate" })],
}),
],
})

Expand All @@ -54,6 +63,12 @@ export async function exportAsPkcs12(
bagValue: new pkijs.CertBag({
parsedValue: cert,
}),
bagAttributes: [
new pkijs.Attribute({
type: OID_PKCS9_FriendlyName,
values: [new asn1js.BmpString({ value: "trust anchor" })],
}),
],
}),
),
],
Expand All @@ -71,6 +86,10 @@ export async function exportAsPkcs12(
bagId: OID_PKCS12_BagId_PKCS8ShroudedKeyBag,
bagValue: pkcs8KeyBag,
bagAttributes: [
new pkijs.Attribute({
type: OID_PKCS9_FriendlyName,
values: [new asn1js.BmpString({ value: "private key" })],
}),
new pkijs.Attribute({
type: OID_PKCS9_LocalKeyId,
values: [new asn1js.OctetString({ valueHex: certKeyId })],
Expand Down
4 changes: 4 additions & 0 deletions packages/web/app/pki/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ export async function deleteClientCertificate(serial: SerialNumberString): Promi
await deleteEndpoint(`api/v1/pki/clients/${serial}`)
}

export async function exportCertificateAuthorityPem(): Promise<string> {
return await getTypedEndpoint(t.string, `api/v1/pki/ca/pem`)
}

export async function exportClientCertificateP12(serial: SerialNumberString, password: string): Promise<string> {
return await postTypedEndpoint(
t.string,
Expand Down
123 changes: 123 additions & 0 deletions packages/web/app/pki/exportDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"use client"

import { FileDownload } from "@mui/icons-material"
import {
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Stack,
TextField,
} from "@mui/material"
import { SerialNumberString } from "@yonagi/common/types/pki/SerialNumberString"
import { FormEvent, useState } from "react"
import { useQuery } from "react-query"

import { exportClientCertificateP12 } from "./actions"
import { base64ToBlob, downloadBlob } from "../../lib/client"
import { useNotifications } from "../../lib/notifications"

export function ExportPkcs12Dialog({
onClose,
open,
serialNumber,
}: {
onClose: () => void
open: boolean
serialNumber: SerialNumberString
}): JSX.Element {
const [password, setPassword] = useState("")

const { isLoading, refetch } = useQuery<unknown, unknown, { password: string }>({
enabled: false,
queryFn: async () => {
const base64 = await exportClientCertificateP12(serialNumber, password)
const blob = base64ToBlob(base64, "application/x-pkcs12")
downloadBlob(blob, `${serialNumber}.p12`)
},
onError: (error) => {
notifyError("Failed to export PKCS#12", String(error))
},
queryKey: ["pki", "download", serialNumber],
retry: false,
})

const handleSubmit = () => {
refetch()
.then(() => {
onClose()
})
.catch((error) => {
notifyError("Failed to export as PKCS#12", String(error))
})
}

const { notifyError } = useNotifications()

return (
<Dialog
onClose={onClose}
open={open}
maxWidth="sm"
fullWidth
PaperProps={{
component: "form",
onSubmit: (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
handleSubmit()
},
}}
>
<DialogTitle>Export PKCS#12</DialogTitle>
<DialogContent>
<Stack spacing={2}>
<TextField
autoFocus
fullWidth
label="Password"
onChange={(e) => {
setPassword(e.currentTarget.value)
}}
required
type="password"
value={password}
variant="filled"
/>
</Stack>
</DialogContent>
<DialogActions>
<Button
disabled={isLoading || password.length === 0}
onClick={() => {
handleSubmit()
}}
startIcon={isLoading ? <CircularProgress size="1em" /> : <FileDownload />}
type="submit"
>
Export
</Button>
<Button onClick={onClose}>Cancel</Button>
</DialogActions>
</Dialog>
)
}

export function useExportPkcs12Dialog({ serialNumber }: { serialNumber: SerialNumberString }) {
const [isOpen, setOpen] = useState(false)
const dialog = ExportPkcs12Dialog({
onClose: () => {
setOpen(false)
},
open: isOpen,
serialNumber,
})

return {
dialog,
open: () => {
setOpen(true)
},
}
}
80 changes: 46 additions & 34 deletions packages/web/app/pki/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,13 @@ import {
deleteCertificateAuthority,
deleteClientCertificate,
deleteServerCertificate,
exportClientCertificateP12,
exportCertificateAuthorityPem,
getPkiSummary,
} from "./actions"
import { useNonce, useQueryHelpers } from "../../lib/client"
import { useExportPkcs12Dialog } from "./exportDialog"
import { downloadBlob, useNonce, useQueryHelpers } from "../../lib/client"
import { ValidatedForm, ValidatedTextField } from "../../lib/forms"
import { useNotifications } from "../../lib/notifications"

const PKI_QUERY_KEY = ["pki", "summary"]

Expand Down Expand Up @@ -89,48 +91,42 @@ function CertificateDetailCell({ children, label }: { children: React.ReactNode;
}

function CertificateDisplayAccordionDetails({
canExportCaPem,
canExportP12,
cert,
delete: submitDelete,
downloadable,
}: {
canExportCaPem?: boolean
canExportP12?: boolean
cert: CertificateSummary
delete: (serial: SerialNumberString) => Promise<unknown>
downloadable?: boolean
}) {
const { invalidate } = useQueryHelpers(PKI_QUERY_KEY)
const { isLoading: isDeleting, mutate: mutateDelete } = useMutation({
mutationFn: async () => await submitDelete(cert.serialNumber),
mutationKey: ["pki", "delete", cert.serialNumber],
onSettled: invalidate,
})
const {
data,
error: exportError,
isLoading: isExporting,
refetch: download,
} = useQuery({
const { notifyError } = useNotifications()

const { isLoading: isExportingCaPem, refetch: refetchCaPem } = useQuery({
enabled: false,
queryFn: async () => {
let blobUrl: string
if (!data) {
const base64 = await exportClientCertificateP12(cert.serialNumber, "neko")
const buffer = Buffer.from(base64, "base64")
const blob = new Blob([buffer], { type: "application/x-pkcs12" })
blobUrl = URL.createObjectURL(blob)
} else {
blobUrl = data
}

const a = document.createElement("a")
a.href = blobUrl
a.download = `${cert.serialNumber}.p12`
a.click()

return blobUrl
const pem = await exportCertificateAuthorityPem()
const blob = new Blob([pem], { type: "application/x-pem-file" })
downloadBlob(blob, `${cert.serialNumber}.crt`)
},
onError: (error) => {
notifyError("Failed to download certificate", String(error))
},
queryKey: ["pki", "download", cert.serialNumber],
retry: false,
})

const { dialog: exportPkcs12Dialog, open: openExportPkcs12Dialog } = useExportPkcs12Dialog({
serialNumber: cert.serialNumber,
})

const [deletePopoverAnchor, setDeletePopoverAnchor] = useState<HTMLElement | null>(null)

return (
Expand Down Expand Up @@ -165,19 +161,31 @@ function CertificateDisplayAccordionDetails({
>
Delete
</Button>
{downloadable && (
{canExportCaPem && (
<Button
color="primary"
disabled={isExporting}
disabled={isExportingCaPem}
onClick={() => {
download().catch(() => {
refetchCaPem().catch(() => {
/* */
})
}}
startIcon={isExporting ? <CircularProgress /> : exportError ? <Dangerous /> : <Download />}
startIcon={isExportingCaPem ? <CircularProgress size="1em" /> : <Download />}
variant="contained"
>
Certificate
</Button>
)}
{canExportP12 && (
<Button
color="primary"
onClick={() => {
openExportPkcs12Dialog()
}}
startIcon={<Download />}
variant="contained"
>
Download
PKCS#12
</Button>
)}
</Stack>
Expand Down Expand Up @@ -210,6 +218,7 @@ function CertificateDisplayAccordionDetails({
Confirm Delete
</Button>
</Popover>
{canExportP12 && exportPkcs12Dialog}
</AccordionDetails>
)
}
Expand Down Expand Up @@ -294,9 +303,10 @@ function CertificateAccordion(
title: string
} & (
| {
canExportCaPem?: boolean
canExportP12?: boolean
cert?: CertificateSummary
delete: (serial: SerialNumberString) => Promise<unknown>
downloadable?: boolean
}
| {
cert?: never
Expand Down Expand Up @@ -327,7 +337,8 @@ function CertificateAccordion(
<CertificateDisplayAccordionDetails
cert={props.cert}
delete={(serial) => props.delete(serial)}
downloadable={props.downloadable}
canExportCaPem={props.canExportCaPem}
canExportP12={props.canExportP12}
/>
) : props.create ? (
<CertificateCreateAccordionDetails create={props.create} />
Expand Down Expand Up @@ -362,6 +373,7 @@ export default function PkiDashboardPage() {
<Box>
<DashboardSectionTitle>Infrastructure</DashboardSectionTitle>
<CertificateAccordion
canExportCaPem
cert={data?.ca}
create={(form) => createCertificateAuthority(form).finally(increaseNonce)}
defaultExpanded
Expand All @@ -386,7 +398,7 @@ export default function PkiDashboardPage() {
<CertificateAccordion
cert={clientCert}
delete={(serial) => deleteClientCertificate(serial)}
downloadable
canExportP12
isLoading={!hasData}
key={clientCert.serialNumber}
title="Client"
Expand Down
Loading
Loading