Skip to content

Commit

Permalink
feat: implement key utils module (#15)
Browse files Browse the repository at this point in the history
* implement key utils module

* fix npm test
  • Loading branch information
ycmjason authored Oct 20, 2024
1 parent 2e00041 commit f32d85f
Show file tree
Hide file tree
Showing 11 changed files with 213 additions and 21 deletions.
22 changes: 21 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ on:
pull_request:

jobs:
fmt-lint-check-test:
fmt-lint-check:
name: "Format & Lint & Type Check"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
Expand All @@ -17,5 +18,24 @@ jobs:
- run: deno fmt --check
- run: deno lint
- run: deno check --doc .
deno-unit:
name: "Unit Test (Deno)"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: denoland/setup-deno@v2
with:
deno-version: v2.x # Run with latest stable Deno.
- run: deno task test:unit
node-unit:
name: "Unit Test (Node.js)"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: denoland/setup-deno@v2
with:
deno-version: v2.x # Run with latest stable Deno.
- uses: actions/setup-node@v4
with:
node-version: ">=20"
- run: deno task build:npm:unit
1 change: 1 addition & 0 deletions deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
".": "./src/mod.ts",
"./DnsUtils": "./src/DnsUtils/mod.ts",
"./CertUtils": "./src/CertUtils/mod.ts",
"./CryptoKeyUtils": "./src/CryptoKeyUtils/mod.ts",
"./generateCSR": "./src/utils/generateCSR.ts",
"./workflows": "./src/AcmeWorkflows.ts"
},
Expand Down
15 changes: 2 additions & 13 deletions src/CertUtils/decodeValidity.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,7 @@
// deno-lint-ignore no-unused-vars -- jsdoc
import type { AcmeOrder } from "../AcmeOrder.ts";
import { decodeSequence, decodeTime } from "../Asn1/Asn1DecodeHelpers.ts";

const getLeafCertificateBase64 = (pem: string): Uint8Array => {
return Uint8Array.from(
[
...atob(
pem.replace("-----BEGIN CERTIFICATE-----", "")
.replace(/-----END CERTIFICATE-----.*/s, "")
.replaceAll(/\s/g, ""),
),
].map((c) => c.charCodeAt(0)),
);
};
import { extractFirstPemObject } from "../utils/pem.ts";

/**
* A function to retrieve your certificate's validity time.
Expand All @@ -37,7 +26,7 @@ export const decodeValidity = (certPem: string): {
notBefore: Date;
notAfter: Date;
} => {
const leaf = getLeafCertificateBase64(certPem);
const leaf = extractFirstPemObject(certPem);

/**
* Leaf ASN.1
Expand Down
4 changes: 4 additions & 0 deletions src/CryptoKeyUtils/PemCryptoKeyPair.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export type PemCryptoKeyPair = {
publicKey: string;
privateKey: string;
};
29 changes: 29 additions & 0 deletions src/CryptoKeyUtils/exportKeyPairToPem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { encodeBase64 } from "../utils/encoding.ts";
import type { PemCryptoKeyPair } from "./PemCryptoKeyPair.ts";

const formatPEM = (base64: string, type: string) => {
const pemHeader = `-----BEGIN ${type}-----`;
const pemFooter = `-----END ${type}-----`;
const pemBody = base64.match(/.{1,64}/g)?.join("\n") ?? "";
return `${pemHeader}\n${pemBody}\n${pemFooter}`;
};

/**
* Export a CryptoKeyPair to PEM strings including the PEM header and footer.
*/
export async function exportKeyPairToPEM(
keyPair: CryptoKeyPair,
): Promise<PemCryptoKeyPair> {
const [spki, pkcs8] = await Promise.all([
crypto.subtle.exportKey("spki", keyPair.publicKey),
crypto.subtle.exportKey("pkcs8", keyPair.privateKey),
]);

const publicKeyPEM = formatPEM(encodeBase64(spki), "PUBLIC KEY");
const privateKeyPEM = formatPEM(encodeBase64(pkcs8), "PRIVATE KEY");

return {
publicKey: publicKeyPEM,
privateKey: privateKeyPEM,
};
}
44 changes: 44 additions & 0 deletions src/CryptoKeyUtils/importKeyPairFromPemPrivateKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { extractFirstPemObject } from "../utils/pem.ts";

async function derivePublicKey(privateKey: CryptoKey): Promise<CryptoKey> {
// d contains the private info of the key
const { d: _discardedPrivateInfo, ...jwkPublic } = {
...await crypto.subtle.exportKey("jwk", privateKey),
key_ops: ["verify"],
};

// Import the modified JWK as a public key
return crypto.subtle.importKey(
"jwk",
jwkPublic,
{
name: "ECDSA",
namedCurve: "P-256",
},
true,
["verify"],
);
}

/**
* Import the private key in PEM format, derive its public key and return the `Promise<CryptoKeyPair>`.
*/
export async function importKeyPairFromPemPrivateKey(
pemPrivateKey: string,
): Promise<CryptoKeyPair> {
const privateKey = await crypto.subtle.importKey(
"pkcs8",
extractFirstPemObject(pemPrivateKey),
{
name: "ECDSA",
namedCurve: "P-256",
},
true,
["sign"],
);

return {
privateKey,
publicKey: await derivePublicKey(privateKey),
};
}
64 changes: 64 additions & 0 deletions src/CryptoKeyUtils/mod.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, expect, it } from "../../test_deps.ts";
import { generateKeyPair } from "../utils/crypto.ts";
import type { PemCryptoKeyPair } from "./mod.ts";
import * as CryptoKeyUtils from "./mod.ts";

describe("CryptoKeyUtils", () => {
it("should export and import to the same key pair", async () => {
const keyPair = await generateKeyPair();
const pemKeyPair = await CryptoKeyUtils.exportKeyPairToPEM(keyPair);
expect(pemKeyPair.privateKey).toMatch(
/^-----BEGIN PRIVATE KEY-----\n.+?\n-----END PRIVATE KEY-----$/s,
);
expect(pemKeyPair.publicKey).toMatch(
/^-----BEGIN PUBLIC KEY-----\n.+?\n-----END PUBLIC KEY-----$/s,
);

const newKeyPair = await CryptoKeyUtils.importKeyPairFromPemPrivateKey(
pemKeyPair.privateKey,
);
expect(newKeyPair.privateKey).not.toBe(keyPair.privateKey);
expect(newKeyPair.publicKey).not.toBe(keyPair.publicKey);

const [newPrivateKeyJwk, newPublicKeyJwk, privateKeyJwk, publicKeyJwk] =
await Promise.all([
crypto.subtle.exportKey("jwk", newKeyPair.privateKey),
crypto.subtle.exportKey("jwk", newKeyPair.publicKey),
crypto.subtle.exportKey("jwk", keyPair.privateKey),
crypto.subtle.exportKey("jwk", keyPair.publicKey),
]);

expect(newPrivateKeyJwk).toEqual(privateKeyJwk);
expect(newPublicKeyJwk).toEqual(publicKeyJwk);
});

it("should import and export to the same key pair pem", async () => {
/**
* generated from this command:
* ```
* PRIVATE_KEY=$(openssl ecparam -name prime256v1 -genkey -noout | openssl pkcs8 -topk8 -nocrypt -outform PEM) && \
* echo "$PRIVATE_KEY" && \
* echo "$PRIVATE_KEY" | openssl ec -pubout
* ```
*/
const pemKeyPair: PemCryptoKeyPair = {
privateKey: `-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQghI1IlgiYmppCyzuK
EsV2SkE0+o+Rd0tlIuCXaHk2pRuhRANCAAQynuucB8UmeBGFdoTbr7BCj1gzqFnu
opWatNSFAi7oVa9k83PoyNlS88jcJ9E5D1WyFu9N1OUippPJ/MdZ+lq6
-----END PRIVATE KEY-----`,
publicKey: `-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEMp7rnAfFJngRhXaE26+wQo9YM6hZ
7qKVmrTUhQIu6FWvZPNz6MjZUvPI3CfROQ9VshbvTdTlIqaTyfzHWfpaug==
-----END PUBLIC KEY-----`,
};

const keyPair = await CryptoKeyUtils.importKeyPairFromPemPrivateKey(
pemKeyPair.privateKey,
);

expect(await CryptoKeyUtils.exportKeyPairToPEM(keyPair)).toEqual(
pemKeyPair,
);
});
});
9 changes: 9 additions & 0 deletions src/CryptoKeyUtils/mod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* @module
*
* Utilities functions for handling CryptoKeys.
*/

export * from "./exportKeyPairToPem.ts";
export * from "./importKeyPairFromPemPrivateKey.ts";
export * from "./PemCryptoKeyPair.ts";
1 change: 1 addition & 0 deletions src/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export * from "./ACME_DIRECTORY_URLS.ts";

export * as AcmeWorkflows from "./AcmeWorkflows.ts";
export * as CertUtils from "./CertUtils/mod.ts";
export * as CryptoKeyUtils from "./CryptoKeyUtils/mod.ts";
export * as DnsUtils from "./DnsUtils/mod.ts";

export * from "./errors.ts";
37 changes: 30 additions & 7 deletions src/utils/encoding.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,39 @@
export const encodeBase64Url = (
input: string | ArrayBuffer | Uint8Array,
): string => {
const bytes: string = (() => {
): string =>
encodeBase64(input)
// https://github.com/denoland/std/pull/3682#issuecomment-2417603682
.replace(/=?=$/, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");

export const encodeBase64 = (input: ArrayBuffer | string | Uint8Array) => {
const str: string = (() => {
if (typeof input === "string") return input;
return String.fromCharCode(
...(input instanceof ArrayBuffer ? new Uint8Array(input) : input),
);
})();
return btoa(str);
};

return btoa(bytes)
// https://github.com/denoland/std/pull/3682#issuecomment-2417603682
.replace(/=?=$/, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");
export const decodeBase64 = (input: string): Uint8Array => {
const binaryString = atob(input);

const bytes = Uint8Array.from(
{ length: binaryString.length },
(_, i) => binaryString.charCodeAt(i),
);

return bytes;
};

/* unused
export const decodeBase64Url = (input: string): Uint8Array =>
decodeBase64(
input
.replace(/-/g, "+")
.replace(/_/g, "/")
.padEnd(input.length + (4 - (input.length % 4)) % 4, "="),
);
*/
8 changes: 8 additions & 0 deletions src/utils/pem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { decodeBase64 } from "./encoding.ts";

export const extractFirstPemObject = (pem: string): Uint8Array =>
decodeBase64(
pem.replace(/-----BEGIN [^-]+-----/, "")
.replace(/-----END [^-]+-----.*$/s, "")
.replaceAll(/\s/g, ""),
);

0 comments on commit f32d85f

Please sign in to comment.