Skip to content

Commit

Permalink
Merge pull request #10 from amphineko/feat-android-support
Browse files Browse the repository at this point in the history
Enhance PKCS#12 experience and Android compatibility
  • Loading branch information
amphineko authored Mar 23, 2024
2 parents 81073c2 + accca31 commit 3c813d5
Show file tree
Hide file tree
Showing 15 changed files with 664 additions and 41 deletions.
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

0 comments on commit 3c813d5

Please sign in to comment.