Related certificates: binding a second certificate of the same subject

One subject, two certificates. A migration to post-quantum signatures leaves a subject holding a classical certificate and a PQC one at the same time, and a protocol that can use both needs each certificate to say the other exists. RFC 9763 does that in two structures: a CSR attribute where the requester proves it holds the certificate it is naming, and a certificate extension where the issuer records which certificate the new one relates to.

requestSignedData builds the bytes the requester signs. Section 3.1 fixes them as the DER IssuerAndSerialNumber followed by the DER BinaryTime, and nothing else. locationInfo travels in the attribute and is outside those bytes, so a verified proof says the requester holds the certificate and says nothing about where that certificate can be fetched. pki.csr.sign takes the whole attribute as relatedCertRequest and encodes the first two fields from this same function, so the bytes signed and the bytes emitted cannot drift apart.

verifyRequest checks that proof against the certificate certID names, through the engine the certificate path uses, so the advertised algorithm is bound to the key the same way. Handing it any other certificate throws: a proof checked against a certificate the request did not name answers a different question than the one asked. The structure names no algorithm, so the identifier comes from the same resolver the signing verbs use, which is what reaches every algorithm they sign with including an RSASSA-PSS key whose parameters belong to its identifier.

certificateHash and matchesCertificate are the extension side. pki.x509.sign takes extensions.relatedCertificate, and the form handed the related certificate computes the digest here, so no caller can name one algorithm while carrying the output of another. The algorithm defaults the way section 4.1 directs, to the hash the related certificate's own signature OID indicates; a certificate signed with Ed25519 or ML-DSA indicates none, and that throws instead of falling back to a hash the document does not name. sha1 is refused: a chosen-prefix collision on a certificate is a demonstrated attack.

The extension is emitted non-critical, which section 4.1 asks for at SHOULD NOT, and a critical one still parses: pki.lint.certificate with the rfc9763 profile grades it, alongside the section's MUST that the extension appear only in the end-entity certificate of a chain.

pki.relatedCert.requestSignedData

since 0.8.42 stable
pki.relatedCert.requestSignedData(spec) -> Buffer

The bytes a relatedCertRequest proof is made over. RFC 9763 sec. 3.1 states them exactly: "the signature field contains a digital signature over the concatenation of DER-encoded IssuerAndSerialNumber and BinaryTime", signed with the key of the certificate certID names. spec takes certID ({ issuer, serialNumber }, where issuer is a Name as DER or a parsed Name record and serialNumber is a positive integer) and requestTime (a Date, or whole seconds since the epoch as a number or a bigint).

locationInfo is outside these bytes, which is the scope the clause draws: a verified proof says the requester holds the certificate, and says nothing about where that certificate can be fetched. A caller that acts on locationInfo is acting on an unauthenticated field.

Example

var issuer = pki.x509.parseDn("CN=Example CA, O=Example");
var signMe = pki.relatedCert.requestSignedData({
  certID: { issuer: issuer.bytes, serialNumber: 42n },
  requestTime: new Date("2027-01-15T00:00:00Z"),
});
signMe.length > 0;   // -> true; sign these bytes with the key of certificate 42

References

pki.relatedCert.certificateHash

since 0.8.42 stable
pki.relatedCert.certificateHash(certificate, digestAlgorithm?) -> { hashAlgorithm, hashValue }

The RelatedCertificate value naming a certificate: the digest of the whole certificate, with the algorithm that produced it. RFC 9763 sec. 4.1 hashes "the entire related certificate", so the input is the certificate's full DER rather than its tbsCertificate.

With no digestAlgorithm, the algorithm is derived the way sec. 4.1 directs: "If there is a hash algorithm explicitly indicated by the related certificate's signature OID (e.g., ecdsa-with-SHA512), that hash algorithm SHOULD also be used for this extension." A certificate whose signature OID indicates no hash, such as one signed with Ed25519 or ML-DSA, leaves that derivation without an answer and throws relatedcert/no-digest; name the algorithm to resolve it.

sha256, sha384, sha512, sha3-256 and sha3-512 are accepted. sha1 is refused, including when it is what the certificate's own signature OID indicates.

Example

async function example() {
  var kp = await pki.key.generate({ name: "ECDSA", namedCurve: "P-256" });
  var spki = await pki.key.export(kp.publicKey, { format: "der" });
  var related = await pki.x509.sign({ subject: "CN=Held", subjectPublicKey: spki, serialNumber: 42n,
    notBefore: new Date("2027-01-01T00:00:00Z"), notAfter: new Date("2028-01-01T00:00:00Z") }, { key: kp.privateKey });
  pki.relatedCert.certificateHash(related).hashAlgorithm;          // -> "sha256", from this certificate's signature OID
  pki.relatedCert.certificateHash(related, "sha512").hashValue.length;   // -> 64
}
example();

References

pki.relatedCert.matchesCertificate

since 0.8.42 stable
pki.relatedCert.matchesCertificate(relatedCertificate, certificate) -> boolean

Whether a parsed relatedCertificate extension value names the given certificate. Takes the value a pki.schema.x509.parse extension carries, { hashAlgorithm, hashValue }, and the candidate certificate's DER, recomputes the digest under the algorithm the value names, and compares in constant time.

A value this build cannot act on throws. A false from an algorithm nobody computed says the certificate does not match, when what happened is that no comparison was made. Only a digest that was computed and disagreed returns false.

Example

async function example() {
  var kp = await pki.key.generate({ name: "ECDSA", namedCurve: "P-256" });
  var spki = await pki.key.export(kp.publicKey, { format: "der" });
  var related = await pki.x509.sign({ subject: "CN=Held", subjectPublicKey: spki, serialNumber: 42n,
    notBefore: new Date("2027-01-01T00:00:00Z"), notAfter: new Date("2028-01-01T00:00:00Z") }, { key: kp.privateKey });
  var leaf = await pki.x509.sign({ subject: "CN=New Key", subjectPublicKey: spki, serialNumber: 43n,
    notBefore: new Date("2027-01-01T00:00:00Z"), notAfter: new Date("2028-01-01T00:00:00Z"),
    extensions: { relatedCertificate: { relatedCertificate: related } } }, { key: kp.privateKey });
  var ext = pki.schema.x509.parse(leaf).extensions
    .filter(function (e) { return e.oid === pki.oid.byName("relatedCertificate"); })[0];
  pki.relatedCert.matchesCertificate(pki.schema.x509.decodeExtension(ext).decoded, related);   // -> true
}
example();

References

pki.relatedCert.verifyRequest

since 0.8.42 stable
pki.relatedCert.verifyRequest(requesterCertificate, certificate, opts?) -> Promise<boolean>

Verify the proof of possession in a relatedCertRequest CSR attribute. requesterCertificate is the value pki.schema.csr.parse carries on that attribute, and certificate is the DER of the certificate its certID names. Returns true when the signature over the bytes pki.relatedCert.requestSignedData describes verifies under that certificate's subject key, and false when a signature was checked and did not verify.

The certificate handed in must be the one certID names: an issuer and serial that do not match throw relatedcert/cert-mismatch, since verifying a proof against a certificate the request did not name answers a different question than the one asked.

The structure carries no algorithm identifier, so one is derived: the key decides the algorithm, resolved through the same resolver pki.x509.sign and pki.csr.sign use, and the digest comes from the hash the certificate's signature OID indicates, which is the relation sec. 4.1 names for the sibling extension. Every algorithm those verbs sign with is therefore reached, including an RSASSA-PSS key whose parameters are part of its algorithm identifier. opts.digestAlgorithm names the digest instead, and a key whose algorithm fixes its own digest, such as an EdDSA or ML-DSA key, refuses that option instead of ignoring it. opts.signatureAlgorithm takes a DER AlgorithmIdentifier outright. A key that cannot sign, such as an ML-KEM or X25519 key, throws relatedcert/unsupported-algorithm.

What the proof covers is certID and requestTime only. locationInfo is outside it, so a true here does not authenticate where the certificate may be fetched.

The signature is read the way every other ASN.1 signature field is, so an ECDSA proof is the DER SEQUENCE { r, s } of RFC 5480 sec. 2, not the fixed-width r || s a WebCrypto sign returns. Handing over the fixed-width form resolves false rather than throwing, because a signature that does not decode is a signature that does not verify; the example re-encodes it.

Options

signatureAlgorithm  A DER `AlgorithmIdentifier` naming the proof's signature algorithm outright, in place of the derivation.

Example

async function example() {
  var kp = await pki.key.generate({ name: "ECDSA", namedCurve: "P-256" });
  var spki = await pki.key.export(kp.publicKey, { format: "der" });
  var held = await pki.x509.sign({ subject: "CN=Held", subjectPublicKey: spki, serialNumber: 42n,
    notBefore: new Date("2027-01-01T00:00:00Z"), notAfter: new Date("2028-01-01T00:00:00Z") }, { key: kp.privateKey });
  var parsed = pki.schema.x509.parse(held);
  var certID = { issuer: parsed.issuer.bytes, serialNumber: parsed.serialNumber };
  var signMe = pki.relatedCert.requestSignedData({ certID: certID, requestTime: 1800000000 });
  // An ECDSA proof goes into the BIT STRING as DER SEQUENCE { r, s }. WebCrypto returns the
  // fixed-width r || s instead, so it is re-encoded here.
  var raw = Buffer.from(await pki.webcrypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, kp.privateKey, signMe));
  var half = raw.length / 2;
  var proof = pki.asn1.build.sequence([
    pki.asn1.build.integer(BigInt("0x" + raw.subarray(0, half).toString("hex"))),
    pki.asn1.build.integer(BigInt("0x" + raw.subarray(half).toString("hex"))),
  ]);
  var csr = await pki.csr.sign({ subject: "CN=New Key", subjectPublicKey: spki,
    relatedCertRequest: { certID: certID, requestTime: 1800000000,
      locationInfo: ["https://certs.example/held.cer"], signature: proof } }, { key: kp.privateKey });
  var attr = pki.schema.csr.parse(csr).attributes
    .filter(function (a) { return a.type === pki.oid.byName("relatedCertRequest"); })[0];
  await pki.relatedCert.verifyRequest(attr.relatedCertRequest, held);   // -> true
}
example();

References