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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ jobs:
uses: codecov/codecov-action@v7
with:
verbose: true
token: ${{ secrets.CODECOV_TOKEN }}

lint:
name: Lint Code
Expand Down
89 changes: 86 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,18 @@ The `enveloped-signature` transform removes only the `Signature` element being v
- `getCanonXml()` finds the loaded signature in the node's document the same way, and removes nothing
when the signature is not there.

### Malformed certificates

A certificate whose encapsulated data is not base64 is rejected rather than decoded as far as it
goes. `Buffer.from(value, "base64")` discards what it does not recognize, so a corrupt certificate
used to reach `KeyInfo`, or Node's crypto, as whatever bytes survived that. What the parser accepts is
described under [X.509 / Key formats](#x509--key-formats).

The error for a value the parser cannot read is `Invalid PEM format.`, in place of the
`Unknown DER format.` that `derToPem()` threw. A `Buffer` holding the bytes of a PEM file is read
as that file rather than base64-encoded, which is what made it a message with a body of
base64-encoded PEM. Both forms of PEM the parser does read produce the same canonical output.

### Deprecated ahead of 7.0

These exports are deprecated and will be removed in 7.0:
Expand All @@ -87,14 +99,15 @@ These exports are deprecated and will be removed in 7.0:
| `encodeSpecialCharactersInAttribute`, `encodeSpecialCharactersInText` | these are the escaping step of `C14nCanonicalization` and `ExclusiveCanonicalization`, so use those; a custom canonicalizer must apply [C14N escaping](https://www.w3.org/TR/xml-c14n#ProcessingModel) itself |
| `isArrayHasLength` | `Array.isArray(x) && x.length > 0` |
| `validateDigestValue` | decode both from base64, then compare with `a.length === b.length && crypto.timingSafeEqual(a, b)` — `timingSafeEqual` alone throws on a length mismatch instead of returning `false`. Never `===` |
| `BASE64_REGEX`, `EXTRACT_X509_CERTS`, `PEM_FORMAT_REGEX` | no replacement; these are internal parsing details |
| `BASE64_REGEX`, `EXTRACT_X509_CERTS`, `PEM_FORMAT_REGEX` | `toPem()`, `pemToDer()` and `pemCertificates()` apply the rules these described, and validate the encapsulated data as well; see [X.509 / Key formats](#x509--key-formats) |
| `derToPem` | `toPem()`, which is the same function under a name that describes it: it takes a PEM message, several of them, base64, or a Buffer, and DER is only one of those |

Calling one prints a `DeprecationWarning` naming its replacement. The three regexes cannot warn —
`util.deprecate` needs a call to intercept — so TypeScript users see the `@deprecated` tag and
JavaScript users get no signal until the names go away.

`derToPem`, `pemToDer`, `normalizePem` and `findAncestorNs` are **not** deprecated and stay
exported.
`toPem`, `pemToDer`, `pemCertificates`, `normalizePem` and `findAncestorNs` are **not**
deprecated and stay exported.

`getReferences()` and `references` are deprecated. Do not use them to obtain signed XML; use
`getSignedReferences()` instead, as shown in [Verifying Xml documents](#verifying-xml-documents).
Expand Down Expand Up @@ -552,6 +565,76 @@ MIIBxDCCAW6gAwIBAgIQxUSX...
-----END CERTIFICATE-----
```

### What the parser accepts

`toPem()`, `pemToDer()` and `pemCertificates()` read
[RFC 7468](https://www.rfc-editor.org/rfc/rfc7468) textual messages, and `toPem()` also reads bare
base64 with a label supplied by the caller. Either form is judged by the same rules, and `toPem()`
returns the same certificate whatever it arrived as: `\n` line endings, lines of 64 characters,
one message after another.

- `toPem(value, label?)` returns canonical PEM. A Buffer that opens with an encapsulation
boundary is read as the bytes of a PEM file and any other as raw DER, so base64 text is given
as a string rather than a Buffer.
- `pemToDer(pem)` returns the decoded bytes of the one message a value holds.
- `pemCertificates(pem)` returns the base64 of each `CERTIFICATE` message and ignores messages of
any other label, so a private key in the same value is never published. It reads certificates
out of a larger value, passing over the explanatory text tools write around a message, while
`toPem()` rewrites the whole value and so must account for all of it.

Accepted:

- `\n`, `\r\n` and `\r` line endings, and a leading UTF-8 BOM.
- any line width, a single line included.
- spaces and tabs anywhere in the encapsulated data. XMLDSig carries a certificate as
[`xs:base64Binary`](https://www.w3.org/TR/xmlschema11-2/#base64Binary), whose lexical space
collapses whitespace, so a pretty-printed document indents it and a value that has been through
a text field may have had its line endings replaced by spaces.
- a blank line after the header, and a blank line anywhere among the lines of base64 given
without boundaries, which is the form an `X509Certificate` element carries. A blank line inside
a message's body is rejected; RFC 7468's body does not have one.
- explanatory text before, after or between the messages, which `pemCertificates()` passes over.
[Section 5.2](https://www.rfc-editor.org/rfc/rfc7468#section-5.2) shows a certificate written
under its subject and issuer lines, and OpenSSL and keytool both write them.
- several messages in one value, of which `toPem()` keeps all, `pemCertificates()` takes the
certificates, and `pemToDer()` takes none.
- any non-empty label of up to 48 characters that RFC 7468's grammar allows, which is every
registered label and the ones OpenSSL adds, such as `RSA PRIVATE KEY`. `PemLabel` names the
registered ones, the longest of which is 21 characters.
- a certificate encoded as BER as well as DER, as
[RFC 7468 section 5.1](https://www.rfc-editor.org/rfc/rfc7468#section-5.1) allows. Its octets are
kept as given, because [XML Signature 1.1](https://www.w3.org/TR/xmldsig-core1/#sec-X509Data)
says an implementation SHOULD NOT alter or re-encode a certificate.
- base64 whose pad bits are not zero, which is written back out with them zeroed, since only that
form is in [`xs:base64Binary`](https://www.w3.org/TR/xmlschema11-2/#base64Binary)'s lexical space.
The octets are the same either way.
- the `Proc-Type` and `DEK-Info` header fields of a traditional encrypted private key, as OpenSSL
and Node write one, so that `pemCertificates()` can read certificates out of a bundle holding
such a key. `toPem()` and `pemToDer()` refuse the key itself, whose data is lost without them.

Rejected, with an error rather than a certificate:

- data outside the base64 alphabet, padding away from the end, or a final quantum that is not
whole, per [RFC 4648 section 4](https://www.rfc-editor.org/rfc/rfc4648#section-4).
- a `CERTIFICATE` whose data is not exactly one X.509 certificate: base64 of something else, a
certificate cut short, or one with more bytes after it. Node's
[`X509Certificate`](https://nodejs.org/api/crypto.html#class-x509certificate) decides whether
the data is a certificate, so it is judged as X.509 and not by its base64 alone.
- a header with no data under it, and a blank line in the middle of a message's data.
- a boundary sharing its line with other text.
[Figure 1](https://www.rfc-editor.org/rfc/rfc7468#section-3) gives an encapsulation boundary a
line of its own, so text on that line is not the explanatory text around a message.
- a value that opens a message it does not close, or one whose header and footer labels disagree.
[Section 3](https://www.rfc-editor.org/rfc/rfc7468#section-3) permits a parser to disregard the
footer's label, but OpenSSL will not read such a message, so neither does this one.
- an empty label, which the `label` production marks as `empty ok`. A message labelled nothing
names no format, and OpenSSL answers one with `ERR_OSSL_UNSUPPORTED`, so `-----BEGIN -----` and
`toPem(data, "")` are both refused.
- a label outside RFC 7468's grammar, which is one holding `--` or opening or closing with a
blank, and one longer than 48 characters. A label is written into both boundaries, so one
holding `--` would produce a message this parser could not read back, and `-----BEGIN ` and
`-----` bracket it in 16 characters, so 48 is the longest whose boundary still fits one line.

### Converting .pfx certificates to pem

If you have .pfx certificates you can convert them to .pem using [openssl](http://www.openssl.org/):
Expand Down
31 changes: 27 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export {
export { SignedXml } from "./signed-xml";
export * from "./types";

export { derToPem, findAncestorNs, normalizePem, pemToDer } from "./utils";
export { findAncestorNs, normalizePem, pemCertificates, pemToDer, toPem } from "./utils";

/*
* `index.ts` used to re-export `./utils` wholesale, so helpers written for `signed-xml.ts` to
Expand Down Expand Up @@ -106,17 +106,40 @@ export const validateDigestValue = deprecate(
"XML_CRYPTO_VALIDATE_DIGEST_VALUE",
);

/**
* @deprecated Will be removed in 7.0. Renamed to `toPem()`, which is what it has always done:
* its input is a PEM message, several of them, base64, or a Buffer of either PEM or DER.
*/
export const derToPem = deprecate(
utils.toPem,
"`derToPem()` is deprecated and will be removed in version 7.0. Use `toPem()` instead.",
"XML_CRYPTO_DER_TO_PEM",
);

/*
* The three regexes below cannot carry a runtime warning: `util.deprecate` wraps a function, and
* a `RegExp` has no call to intercept. TypeScript consumers see the `@deprecated` tag; JavaScript
* consumers get no signal until the name goes away in 7.0.
*
* They are defined here rather than in `utils.ts` because the parser no longer uses them. They
* are frozen copies of what 6.1 exported, so that a consumer still reading them sees what it has
* always seen until 7.0 removes them.
*/

/** @deprecated Will be removed in 7.0. This is an internal parsing detail with no replacement. */
export const PEM_FORMAT_REGEX = utils.PEM_FORMAT_REGEX;
export const PEM_FORMAT_REGEX = new RegExp(
"^-----BEGIN [A-Z\x20]{1,48}-----([^-]*)-----END [A-Z\x20]{1,48}-----$",
"s",
);

/** @deprecated Will be removed in 7.0. This is an internal parsing detail with no replacement. */
export const EXTRACT_X509_CERTS = utils.EXTRACT_X509_CERTS;
export const EXTRACT_X509_CERTS = new RegExp(
"-----BEGIN CERTIFICATE-----[^-]*-----END CERTIFICATE-----",
"g",
);

/** @deprecated Will be removed in 7.0. This is an internal parsing detail with no replacement. */
export const BASE64_REGEX = utils.BASE64_REGEX;
export const BASE64_REGEX = new RegExp(
"^(?:[A-Za-z0-9\\+\\/]{4}\\n{0,1})*(?:[A-Za-z0-9\\+\\/]{2}==|[A-Za-z0-9\\+\\/]{3}=)?$",
"s",
);
21 changes: 7 additions & 14 deletions src/signed-xml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,23 +228,16 @@ export class SignedXml {
publicCert = publicCert.toString("latin1");
}

let publicCertMatches: string[] = [];
if (typeof publicCert === "string") {
publicCertMatches = publicCert.match(utils.EXTRACT_X509_CERTS) || [];
}
// A KeyObject holds a key and never a certificate, so there is no X509Data to build from it.
const certificates = typeof publicCert === "string" ? utils.pemCertificates(publicCert) : [];

// X509Data requires at least one child: https://www.w3.org/TR/xmldsig-core1/#sec-X509Data
if (publicCertMatches.length === 0) {
if (certificates.length === 0) {
return null;
}

const x509Certs = publicCertMatches
.map(
(c) =>
`<${prefix}X509Certificate>${utils
.pemToDer(c)
.toString("base64")}</${prefix}X509Certificate>`,
)
const x509Certs = certificates
.map((cert) => `<${prefix}X509Certificate>${cert}</${prefix}X509Certificate>`)
.join("");

return `<${prefix}X509Data>${x509Certs}</${prefix}X509Data>`;
Expand All @@ -260,8 +253,8 @@ export class SignedXml {
static getCertFromKeyInfo(keyInfo?: Node | null): string | null {
if (keyInfo != null) {
const cert = xpath.select1(".//*[local-name(.)='X509Certificate']", keyInfo);
if (isDomNode.isNodeLike(cert)) {
return utils.derToPem(cert.textContent ?? "", "CERTIFICATE");
if (isDomNode.isElementNode(cert)) {
return utils.toPem(cert.textContent, "CERTIFICATE");
}
}

Expand Down
19 changes: 19 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,25 @@ export type HashAlgorithmType =
| "http://www.w3.org/2001/04/xmlenc#sha512"
| string;

/**
* The label carried by both boundaries of a PEM message. The values listed are the ones RFC 7468
* defines; any other label its grammar allows is accepted too, when it is not empty and not over
* 48 characters, because OpenSSL and the wider ecosystem use others, such as `RSA PRIVATE KEY`.
*
* @see https://www.rfc-editor.org/rfc/rfc7468
*/
export type PemLabel =
| "CERTIFICATE"
| "X509 CRL"
| "CERTIFICATE REQUEST"
| "PKCS7"
| "CMS"
| "PRIVATE KEY"
| "ENCRYPTED PRIVATE KEY"
| "ATTRIBUTE CERTIFICATE"
| "PUBLIC KEY"
| string;

export type SignatureAlgorithmType =
| "http://www.w3.org/2000/09/xmldsig#rsa-sha1"
| "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"
Expand Down
Loading