pem.js 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
  1. import { base64 } from "../encoding/base64.js";
  2. const LABEL_REGEX = /^[A-Z0-9][A-Z0-9 ._-]*[A-Z0-9]$/i;
  3. const PEM_BLOCK_REGEX = /-----BEGIN ([^-]+)-----([\s\S]*?)-----END \1-----/g;
  4. function assertLabel(label) {
  5. if (!LABEL_REGEX.test(label)) {
  6. throw new TypeError(`Invalid PEM label '${label}'`);
  7. }
  8. }
  9. function wrap(text, lineLength) {
  10. const result = [];
  11. for (let i = 0; i < text.length; i += lineLength) {
  12. result.push(text.slice(i, i + lineLength));
  13. }
  14. return result;
  15. }
  16. function parseBody(body) {
  17. const normalized = body.trim().replace(/\r\n/g, "\n");
  18. const lines = normalized.split("\n").map((line) => line.trim()).filter(Boolean);
  19. const headers = {};
  20. let index = 0;
  21. for (; index < lines.length; index++) {
  22. const line = lines[index];
  23. const separator = line.indexOf(":");
  24. if (separator <= 0) {
  25. break;
  26. }
  27. headers[line.slice(0, separator).trim()] = line.slice(separator + 1).trim();
  28. }
  29. return {
  30. headers: Object.keys(headers).length ? headers : undefined,
  31. base64Lines: lines.slice(index),
  32. base64Text: lines.slice(index).join(""),
  33. };
  34. }
  35. function detectNewline(text) {
  36. return /\r\n/.test(text) ? "\r\n" : "\n";
  37. }
  38. function collectBlocks(text, options = {}) {
  39. const blocks = [];
  40. const requestedLabel = options.label;
  41. let match;
  42. PEM_BLOCK_REGEX.lastIndex = 0;
  43. while ((match = PEM_BLOCK_REGEX.exec(text))) {
  44. const label = match[1].trim();
  45. if (requestedLabel && label !== requestedLabel) {
  46. continue;
  47. }
  48. assertLabel(label);
  49. const parsed = parseBody(match[2]);
  50. blocks.push({
  51. label,
  52. data: base64.decode(parsed.base64Text),
  53. headers: parsed.headers,
  54. lineLength: parsed.base64Lines[0]?.length ?? 64,
  55. newline: detectNewline(match[0]),
  56. });
  57. }
  58. if (options.strict && blocks.length === 0) {
  59. throw new TypeError(requestedLabel
  60. ? `No PEM block with label '${requestedLabel}' was found`
  61. : "No PEM blocks were found");
  62. }
  63. return blocks;
  64. }
  65. export function encode(label, data, options = {}) {
  66. assertLabel(label);
  67. const lineLength = options.lineLength ?? 64;
  68. if (!Number.isInteger(lineLength) || lineLength < 1) {
  69. throw new RangeError("PEM lineLength must be a positive integer");
  70. }
  71. const newline = options.newline ?? "\n";
  72. const lines = [`-----BEGIN ${label}-----`];
  73. if (options.headers) {
  74. for (const [name, value] of Object.entries(options.headers)) {
  75. lines.push(`${name}: ${value}`);
  76. }
  77. lines.push("");
  78. }
  79. lines.push(...wrap(base64.encode(data), lineLength));
  80. lines.push(`-----END ${label}-----`);
  81. return `${lines.join(newline)}${newline}`;
  82. }
  83. export function encodeMany(blocks, options = {}) {
  84. return blocks.map((block) => encode(block.label, block.data, { ...options, headers: block.headers ?? options.headers })).join("");
  85. }
  86. export function decode(text, options = {}) {
  87. return collectBlocks(text, options).map(({ lineLength: _lineLength, newline: _newline, ...block }) => block);
  88. }
  89. export function find(text, label) {
  90. return decode(text, { label })[0];
  91. }
  92. export function findAll(text, label) {
  93. return decode(text, { label });
  94. }
  95. export function decodeFirst(text, label) {
  96. const [block] = decode(text, { label, strict: true });
  97. return block.data;
  98. }
  99. export function parse(text, options = {}) {
  100. const [block] = collectBlocks(text, { ...options, strict: true });
  101. const format = {
  102. label: block.label,
  103. headers: block.headers,
  104. lineLength: block.lineLength,
  105. newline: block.newline,
  106. };
  107. return {
  108. bytes: block.data,
  109. format,
  110. normalized: encode(block.label, block.data, format),
  111. };
  112. }
  113. export function format(data, value) {
  114. return encode(value.label, data, value);
  115. }
  116. export const pem = { decode, decodeFirst, encode, encodeMany, find, findAll, format, parse };
  117. export const pemConverter = {
  118. name: "pem",
  119. encode: (data, options) => {
  120. if (!options?.label) {
  121. throw new TypeError("PEM label is required");
  122. }
  123. return encode(options.label, data, options);
  124. },
  125. decode: (text, options) => decodeFirst(text, options?.label),
  126. format,
  127. is: (text) => typeof text === "string" && /-----BEGIN [^-]+-----/.test(text),
  128. parse,
  129. };