Skip to content

Commit f4995db

Browse files
authored
Merge pull request #1887 from robinsandhu/feature/parse-crl
Add operation for parsing X.509 CRLs
2 parents 3822c6c + 3deb121 commit f4995db

File tree

3 files changed

+723
-0
lines changed

3 files changed

+723
-0
lines changed

src/core/config/Categories.json

+1
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@
164164
"name": "Public Key",
165165
"ops": [
166166
"Parse X.509 certificate",
167+
"Parse X.509 CRL",
167168
"Parse ASN.1 hex string",
168169
"PEM to Hex",
169170
"Hex to PEM",

src/core/operations/ParseX509CRL.mjs

+391
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,391 @@
1+
/**
2+
* @author robinsandhu
3+
* @copyright Crown Copyright 2024
4+
* @license Apache-2.0
5+
*/
6+
7+
import r from "jsrsasign";
8+
import Operation from "../Operation.mjs";
9+
import { fromBase64 } from "../lib/Base64.mjs";
10+
import { toHex } from "../lib/Hex.mjs";
11+
import { formatDnObj } from "../lib/PublicKey.mjs";
12+
import OperationError from "../errors/OperationError.mjs";
13+
import Utils from "../Utils.mjs";
14+
15+
/**
16+
* Parse X.509 CRL operation
17+
*/
18+
class ParseX509CRL extends Operation {
19+
20+
/**
21+
* ParseX509CRL constructor
22+
*/
23+
constructor() {
24+
super();
25+
26+
this.name = "Parse X.509 CRL";
27+
this.module = "PublicKey";
28+
this.description = "Parse Certificate Revocation List (CRL)";
29+
this.infoURL = "https://wikipedia.org/wiki/Certificate_revocation_list";
30+
this.inputType = "string";
31+
this.outputType = "string";
32+
this.args = [
33+
{
34+
"name": "Input format",
35+
"type": "option",
36+
"value": ["PEM", "DER Hex", "Base64", "Raw"]
37+
}
38+
];
39+
this.checks = [
40+
{
41+
"pattern": "^-+BEGIN X509 CRL-+\\r?\\n[\\da-z+/\\n\\r]+-+END X509 CRL-+\\r?\\n?$",
42+
"flags": "i",
43+
"args": ["PEM"]
44+
}
45+
];
46+
}
47+
48+
/**
49+
* @param {string} input
50+
* @param {Object[]} args
51+
* @returns {string} Human-readable description of a Certificate Revocation List (CRL).
52+
*/
53+
run(input, args) {
54+
if (!input.length) {
55+
return "No input";
56+
}
57+
58+
const inputFormat = args[0];
59+
60+
let undefinedInputFormat = false;
61+
try {
62+
switch (inputFormat) {
63+
case "DER Hex":
64+
input = input.replace(/\s/g, "").toLowerCase();
65+
break;
66+
case "PEM":
67+
break;
68+
case "Base64":
69+
input = toHex(fromBase64(input, null, "byteArray"), "");
70+
break;
71+
case "Raw":
72+
input = toHex(Utils.strToArrayBuffer(input), "");
73+
break;
74+
default:
75+
undefinedInputFormat = true;
76+
}
77+
} catch (e) {
78+
throw "Certificate load error (non-certificate input?)";
79+
}
80+
if (undefinedInputFormat) throw "Undefined input format";
81+
82+
const crl = new r.X509CRL(input);
83+
84+
let out = `Certificate Revocation List (CRL):
85+
Version: ${crl.getVersion() === null ? "1 (0x0)" : "2 (0x1)"}
86+
Signature Algorithm: ${crl.getSignatureAlgorithmField()}
87+
Issuer:\n${formatDnObj(crl.getIssuer(), 8)}
88+
Last Update: ${generalizedDateTimeToUTC(crl.getThisUpdate())}
89+
Next Update: ${generalizedDateTimeToUTC(crl.getNextUpdate())}\n`;
90+
91+
if (crl.getParam().ext !== undefined) {
92+
out += `\tCRL extensions:\n${formatCRLExtensions(crl.getParam().ext, 8)}\n`;
93+
}
94+
95+
out += `Revoked Certificates:\n${formatRevokedCertificates(crl.getRevCertArray(), 4)}
96+
Signature Value:\n${formatCRLSignature(crl.getSignatureValueHex(), 8)}`;
97+
98+
return out;
99+
}
100+
}
101+
102+
/**
103+
* Generalized date time string to UTC.
104+
* @param {string} datetime
105+
* @returns UTC datetime string.
106+
*/
107+
function generalizedDateTimeToUTC(datetime) {
108+
// Ensure the string is in the correct format
109+
if (!/^\d{12,14}Z$/.test(datetime)) {
110+
throw new OperationError(`failed to format datetime string ${datetime}`);
111+
}
112+
113+
// Extract components
114+
let centuary = "20";
115+
if (datetime.length === 15) {
116+
centuary = datetime.substring(0, 2);
117+
datetime = datetime.slice(2);
118+
}
119+
const year = centuary + datetime.substring(0, 2);
120+
const month = datetime.substring(2, 4);
121+
const day = datetime.substring(4, 6);
122+
const hour = datetime.substring(6, 8);
123+
const minute = datetime.substring(8, 10);
124+
const second = datetime.substring(10, 12);
125+
126+
// Construct ISO 8601 format string
127+
const isoString = `${year}-${month}-${day}T${hour}:${minute}:${second}Z`;
128+
129+
// Parse using standard Date object
130+
const isoDateTime = new Date(isoString);
131+
132+
return isoDateTime.toUTCString();
133+
}
134+
135+
/**
136+
* Format CRL extensions.
137+
* @param {r.ExtParam[] | undefined} extensions
138+
* @param {Number} indent
139+
* @returns Formatted string detailing CRL extensions.
140+
*/
141+
function formatCRLExtensions(extensions, indent) {
142+
if (Array.isArray(extensions) === false || extensions.length === 0) {
143+
return indentString(`No CRL extensions.`, indent);
144+
}
145+
146+
let out = ``;
147+
148+
extensions.sort((a, b) => {
149+
if (!Object.hasOwn(a, "extname") || !Object.hasOwn(b, "extname")) {
150+
return 0;
151+
}
152+
if (a.extname < b.extname) {
153+
return -1;
154+
} else if (a.extname === b.extname) {
155+
return 0;
156+
} else {
157+
return 1;
158+
}
159+
});
160+
161+
extensions.forEach((ext) => {
162+
if (!Object.hasOwn(ext, "extname")) {
163+
throw new OperationError(`CRL entry extension object missing 'extname' key: ${ext}`);
164+
}
165+
switch (ext.extname) {
166+
case "authorityKeyIdentifier":
167+
out += `X509v3 Authority Key Identifier:\n`;
168+
if (Object.hasOwn(ext, "kid")) {
169+
out += `\tkeyid:${colonDelimitedHexFormatString(ext.kid.hex.toUpperCase())}\n`;
170+
}
171+
if (Object.hasOwn(ext, "issuer")) {
172+
out += `\tDirName:${ext.issuer.str}\n`;
173+
}
174+
if (Object.hasOwn(ext, "sn")) {
175+
out += `\tserial:${colonDelimitedHexFormatString(ext.sn.hex.toUpperCase())}\n`;
176+
}
177+
break;
178+
case "cRLDistributionPoints":
179+
out += `X509v3 CRL Distribution Points:\n`;
180+
ext.array.forEach((distPoint) => {
181+
const fullName = `Full Name:\n${formatGeneralNames(distPoint.dpname.full, 4)}`;
182+
out += indentString(fullName, 4) + "\n";
183+
});
184+
break;
185+
case "cRLNumber":
186+
if (!Object.hasOwn(ext, "num")) {
187+
throw new OperationError(`'cRLNumber' CRL entry extension missing 'num' key: ${ext}`);
188+
}
189+
out += `X509v3 CRL Number:\n\t${ext.num.hex.toUpperCase()}\n`;
190+
break;
191+
case "issuerAltName":
192+
out += `X509v3 Issuer Alternative Name:\n${formatGeneralNames(ext.array, 4)}\n`;
193+
break;
194+
default:
195+
out += `${ext.extname}:\n`;
196+
out += `\tUnsupported CRL extension. Try openssl CLI.\n`;
197+
break;
198+
}
199+
});
200+
201+
return indentString(chop(out), indent);
202+
}
203+
204+
/**
205+
* Format general names array.
206+
* @param {Object[]} names
207+
* @returns Multi-line formatted string describing all supported general name types.
208+
*/
209+
function formatGeneralNames(names, indent) {
210+
let out = ``;
211+
212+
names.forEach((name) => {
213+
const key = Object.keys(name)[0];
214+
215+
switch (key) {
216+
case "ip":
217+
out += `IP:${name.ip}\n`;
218+
break;
219+
case "dns":
220+
out += `DNS:${name.dns}\n`;
221+
break;
222+
case "uri":
223+
out += `URI:${name.uri}\n`;
224+
break;
225+
case "rfc822":
226+
out += `EMAIL:${name.rfc822}\n`;
227+
break;
228+
case "dn":
229+
out += `DIR:${name.dn.str}\n`;
230+
break;
231+
case "other":
232+
out += `OtherName:${name.other.oid}::${Object.values(name.other.value)[0].str}\n`;
233+
break;
234+
default:
235+
out += `${key}: unsupported general name type`;
236+
break;
237+
}
238+
});
239+
240+
return indentString(chop(out), indent);
241+
}
242+
243+
/**
244+
* Colon-delimited hex formatted output.
245+
* @param {string} hexString Hex String
246+
* @returns String representing input hex string with colon delimiter.
247+
*/
248+
function colonDelimitedHexFormatString(hexString) {
249+
if (hexString.length % 2 !== 0) {
250+
hexString = "0" + hexString;
251+
}
252+
253+
return chop(hexString.replace(/(..)/g, "$&:"));
254+
}
255+
256+
/**
257+
* Format revoked certificates array
258+
* @param {r.RevokedCertificate[] | null} revokedCertificates
259+
* @param {Number} indent
260+
* @returns Multi-line formatted string output of revoked certificates array
261+
*/
262+
function formatRevokedCertificates(revokedCertificates, indent) {
263+
if (Array.isArray(revokedCertificates) === false || revokedCertificates.length === 0) {
264+
return indentString("No Revoked Certificates.", indent);
265+
}
266+
267+
let out=``;
268+
269+
revokedCertificates.forEach((revCert) => {
270+
if (!Object.hasOwn(revCert, "sn") || !Object.hasOwn(revCert, "date")) {
271+
throw new OperationError("invalid revoked certificate object, missing either serial number or date");
272+
}
273+
274+
out += `Serial Number: ${revCert.sn.hex.toUpperCase()}
275+
Revocation Date: ${generalizedDateTimeToUTC(revCert.date)}\n`;
276+
if (Object.hasOwn(revCert, "ext") && Array.isArray(revCert.ext) && revCert.ext.length !== 0) {
277+
out += `\tCRL entry extensions:\n${indentString(formatCRLEntryExtensions(revCert.ext), 2*indent)}\n`;
278+
}
279+
});
280+
281+
return indentString(chop(out), indent);
282+
}
283+
284+
/**
285+
* Format CRL entry extensions.
286+
* @param {Object[]} exts
287+
* @returns Formatted multi-line string describing CRL entry extensions.
288+
*/
289+
function formatCRLEntryExtensions(exts) {
290+
let out = ``;
291+
292+
const crlReasonCodeToReasonMessage = {
293+
0: "Unspecified",
294+
1: "Key Compromise",
295+
2: "CA Compromise",
296+
3: "Affiliation Changed",
297+
4: "Superseded",
298+
5: "Cessation Of Operation",
299+
6: "Certificate Hold",
300+
8: "Remove From CRL",
301+
9: "Privilege Withdrawn",
302+
10: "AA Compromise",
303+
};
304+
305+
const holdInstructionOIDToName = {
306+
"1.2.840.10040.2.1": "Hold Instruction None",
307+
"1.2.840.10040.2.2": "Hold Instruction Call Issuer",
308+
"1.2.840.10040.2.3": "Hold Instruction Reject",
309+
};
310+
311+
exts.forEach((ext) => {
312+
if (!Object.hasOwn(ext, "extname")) {
313+
throw new OperationError(`CRL entry extension object missing 'extname' key: ${ext}`);
314+
}
315+
switch (ext.extname) {
316+
case "cRLReason":
317+
if (!Object.hasOwn(ext, "code")) {
318+
throw new OperationError(`'cRLReason' CRL entry extension missing 'code' key: ${ext}`);
319+
}
320+
out += `X509v3 CRL Reason Code:
321+
${Object.hasOwn(crlReasonCodeToReasonMessage, ext.code) ? crlReasonCodeToReasonMessage[ext.code] : `invalid reason code: ${ext.code}`}\n`;
322+
break;
323+
case "2.5.29.23": // Hold instruction
324+
out += `Hold Instruction Code:\n\t${Object.hasOwn(holdInstructionOIDToName, ext.extn.oid) ? holdInstructionOIDToName[ext.extn.oid] : `${ext.extn.oid}: unknown hold instruction OID`}\n`;
325+
break;
326+
case "2.5.29.24": // Invalidity Date
327+
out += `Invalidity Date:\n\t${generalizedDateTimeToUTC(ext.extn.gentime.str)}\n`;
328+
break;
329+
default:
330+
out += `${ext.extname}:\n`;
331+
out += `\tUnsupported CRL entry extension. Try openssl CLI.\n`;
332+
break;
333+
}
334+
});
335+
336+
return chop(out);
337+
}
338+
339+
/**
340+
* Format CRL signature.
341+
* @param {String} sigHex
342+
* @param {Number} indent
343+
* @returns String representing hex signature value formatted on multiple lines.
344+
*/
345+
function formatCRLSignature(sigHex, indent) {
346+
if (sigHex.length % 2 !== 0) {
347+
sigHex = "0" + sigHex;
348+
}
349+
350+
return indentString(formatMultiLine(chop(sigHex.replace(/(..)/g, "$&:"))), indent);
351+
}
352+
353+
/**
354+
* Format string onto multiple lines.
355+
* @param {string} longStr
356+
* @returns String as a multi-line string.
357+
*/
358+
function formatMultiLine(longStr) {
359+
const lines = [];
360+
361+
for (let remain = longStr ; remain !== "" ; remain = remain.substring(54)) {
362+
lines.push(remain.substring(0, 54));
363+
}
364+
365+
return lines.join("\n");
366+
}
367+
368+
/**
369+
* Indent a multi-line string by n spaces.
370+
* @param {string} input String
371+
* @param {number} spaces How many leading spaces
372+
* @returns Indented string.
373+
*/
374+
function indentString(input, spaces) {
375+
const indent = " ".repeat(spaces);
376+
return input.replace(/^/gm, indent);
377+
}
378+
379+
/**
380+
* Remove last character from a string.
381+
* @param {string} s String
382+
* @returns Chopped string.
383+
*/
384+
function chop(s) {
385+
if (s.length < 1) {
386+
return s;
387+
}
388+
return s.substring(0, s.length - 1);
389+
}
390+
391+
export default ParseX509CRL;

0 commit comments

Comments
 (0)