Skip to content
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
18 changes: 18 additions & 0 deletions src/lib/crypto/leaf-key.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,24 @@ describe("generateLeafKeyAndCSR", () => {
await expect(csr.verify()).resolves.toBe(true);
});

// CryptOS nodes certify ECDSA subject keys on P-384 only, and the external
// CAs used for operator credentials expect P-384 too (#138).
it("mints a P-384 key and signs the CSR with ECDSA-SHA384", async () => {
const { csrDer, privateKey } = await generateLeafKeyAndCSR({
sans: [],
subjectCn: "op.acme.example",
});

expect((privateKey.algorithm as EcKeyAlgorithm).namedCurve).toBe("P-384");

const csr = new Pkcs10CertificateRequest(toArrayBuffer(csrDer));
const publicKey = await csr.publicKey.export();
expect((publicKey.algorithm as EcKeyAlgorithm).namedCurve).toBe("P-384");
expect(csr.signatureAlgorithm.name).toBe("ECDSA");
expect((csr.signatureAlgorithm as EcdsaParams).hash).toMatchObject({ name: "SHA-384" });
await expect(csr.verify()).resolves.toBe(true);
});

it("carries the SANs as a subjectAltName extension", async () => {
const { csrDer } = await generateLeafKeyAndCSR({
sans: ["a.acme.example", "b.acme.example"],
Expand Down
17 changes: 10 additions & 7 deletions src/lib/crypto/leaf-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,14 @@ import {
SubjectAlternativeNameExtension,
} from "@peculiar/x509";

// The leaf keypair is ECDSA P-256, generated in the browser with WebCrypto.
// The private key is extractable so it can be exported -- but only ever as a
// passphrase-encrypted PKCS#8, never in plaintext.
const LEAF_KEY_ALGORITHM: EcKeyGenParams = { name: "ECDSA", namedCurve: "P-256" };
const CSR_SIGNING_ALGORITHM: EcdsaParams = { name: "ECDSA", hash: "SHA-256" };
// The leaf keypair is ECDSA P-384, generated in the browser with WebCrypto, and
// the CSR is signed with ECDSA-SHA384 to match the curve. A CryptOS node
// certifies ECDSA subject keys on P-384 only, and the external CAs used for
// operator credentials expect P-384 as well (#138). The private key is
// extractable so it can be exported -- but only ever as a passphrase-encrypted
// PKCS#8, never in plaintext.
const LEAF_KEY_ALGORITHM: EcKeyGenParams = { name: "ECDSA", namedCurve: "P-384" };
const CSR_SIGNING_ALGORITHM: EcdsaParams = { name: "ECDSA", hash: "SHA-384" };

// The 18-character floor is a hard, in-code guard on every export path -- the
// UI never gets to skip it, and there is no plaintext branch to fall back to.
Expand All @@ -42,12 +45,12 @@ export const PBKDF2_ITERATIONS = 210_000;
export const SALT_BYTES = 16;
export const IV_BYTES = 16;

// generateLeafKeyAndCSR mints an extractable ECDSA P-256 keypair in the
// generateLeafKeyAndCSR mints an extractable ECDSA P-384 keypair in the
// browser and builds a PKCS#10 CSR carrying the subject CN and, when SANs are
// supplied, a subjectAltName extension. Only the DER CSR is meant to leave the
// browser; privateKey stays in memory for the caller to export on demand.
export const generateLeafKeyAndCSR = async (params: {
keyAlg?: "ECDSA-P256";
keyAlg?: "ECDSA-P384";
sans: string[];
subjectCn: string;
}): Promise<{ csrDer: Uint8Array; privateKey: CryptoKey }> => {
Expand Down
106 changes: 106 additions & 0 deletions src/lib/crypto/pkcs12.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,90 @@ const dummyCertDer = new Uint8Array([0x30, 0x03, 0x02, 0x01, 0x2a]);

const STRONG = "correct-horse-battery-staple";

// A minimal DER reader for the round-trip test: it only has to walk the shapes
// assemblePkcs12 emits (definite lengths, no high tag numbers).
interface Tlv {
tag: number;
content: Uint8Array;
}

const readTlv = (bytes: Uint8Array, offset: number): { next: number; tlv: Tlv } => {
const tag = bytes[offset];
let len = bytes[offset + 1];
let start = offset + 2;
if (len & 0x80) {
const n = len & 0x7f;
len = 0;
for (let k = 0; k < n; k++) len = (len << 8) | bytes[start + k];
start += n;
}
return { next: start + len, tlv: { content: bytes.subarray(start, start + len), tag } };
};

const children = (content: Uint8Array): Tlv[] => {
const out: Tlv[] = [];
let offset = 0;
while (offset < content.length) {
const { next, tlv } = readTlv(content, offset);
out.push(tlv);
offset = next;
}
return out;
};

const only = (bytes: Uint8Array): Tlv => readTlv(bytes, 0).tlv;

const bufferOf = (bytes: Uint8Array): ArrayBuffer => {
const copy = new ArrayBuffer(bytes.byteLength);
new Uint8Array(copy).set(bytes);
return copy;
};

// unshroudKey pulls the shrouded-key bag out of a PFX, and undoes its PBES2
// (PBKDF2-HMAC-SHA256 + AES-256-CBC) envelope, returning the plain PKCS#8.
const unshroudKey = async (pfx: Uint8Array, passphrase: string): Promise<Uint8Array> => {
// PFX ::= SEQUENCE { version, authSafe ContentInfo, macData }
const [, authSafe] = children(only(pfx).content);
// ContentInfo ::= SEQUENCE { id-data, [0] OCTET STRING (AuthenticatedSafe) }
const authenticatedSafe = only(only(children(authSafe.content)[1].content).content);
// The first ContentInfo in the AuthenticatedSafe carries the key SafeContents.
const [keyContentInfo] = children(authenticatedSafe.content);
const safeContents = only(only(children(keyContentInfo.content)[1].content).content);
const [safeBag] = children(safeContents.content);
// SafeBag ::= SEQUENCE { bagId, [0] EncryptedPrivateKeyInfo }
const epki = only(children(safeBag.content)[1].content);
const [algorithm, encryptedData] = children(epki.content);
const [, pbes2Params] = children(algorithm.content);
const [kdf, scheme] = children(pbes2Params.content);
const [salt, iterations] = children(children(kdf.content)[1].content);
const iv = children(scheme.content)[1];

let iterationCount = 0;
for (const b of iterations.content) iterationCount = iterationCount * 256 + b;

const baseKey = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(passphrase),
"PBKDF2",
false,
["deriveKey"],
);
const aesKey = await crypto.subtle.deriveKey(
{ hash: "SHA-256", iterations: iterationCount, name: "PBKDF2", salt: bufferOf(salt.content) },
baseKey,
{ length: 256, name: "AES-CBC" },
false,
["decrypt"],
);
return new Uint8Array(
await crypto.subtle.decrypt(
{ iv: bufferOf(iv.content), name: "AES-CBC" },
aesKey,
bufferOf(encryptedData.content),
),
);
};

describe("assemblePkcs12", () => {
it("rejects a passphrase shorter than the floor", async () => {
const { privateKey } = await generateLeafKeyAndCSR({ sans: [], subjectCn: "op" });
Expand Down Expand Up @@ -57,6 +141,28 @@ describe("assemblePkcs12", () => {
expect(asText.includes(STRONG)).toBe(false);
});

// The browser now mints P-384 operator keys (#138); the shrouded key bag must
// carry that key intact so the .p12 imports as the same P-384 key.
it("round-trips a P-384 key through the shrouded key bag", async () => {
const { privateKey } = await generateLeafKeyAndCSR({ sans: [], subjectCn: "op" });
expect((privateKey.algorithm as EcKeyAlgorithm).namedCurve).toBe("P-384");

const pfx = await assemblePkcs12(dummyCertDer, privateKey, STRONG);
const pkcs8 = await unshroudKey(pfx, STRONG);

const original = new Uint8Array(await crypto.subtle.exportKey("pkcs8", privateKey));
expect(pkcs8).toEqual(original);

const imported = await crypto.subtle.importKey(
"pkcs8",
bufferOf(pkcs8),
{ name: "ECDSA", namedCurve: "P-384" },
true,
["sign"],
);
expect((imported.algorithm as EcKeyAlgorithm).namedCurve).toBe("P-384");
});

it("is deterministic in structure but not in ciphertext (random salt/iv)", async () => {
const { privateKey } = await generateLeafKeyAndCSR({ sans: [], subjectCn: "op" });
const a = await assemblePkcs12(dummyCertDer, privateKey, STRONG);
Expand Down
Loading