diff --git a/deps/ncrypto/ncrypto.cc b/deps/ncrypto/ncrypto.cc index a28b9cd209e9..06c6e3b6064c 100644 --- a/deps/ncrypto/ncrypto.cc +++ b/deps/ncrypto/ncrypto.cc @@ -46,6 +46,15 @@ using BignumCtxPointer = DeleteFnPtr; using BignumGenCallbackPointer = DeleteFnPtr; using NetscapeSPKIPointer = DeleteFnPtr; +struct ASN1SequenceDeleter final { + void operator()(ASN1_SEQUENCE_ANY* sequence) const { + sk_ASN1_TYPE_pop_free(sequence, ASN1_TYPE_free); + } +}; + +using ASN1SequencePointer = + std::unique_ptr; + #if NCRYPTO_USE_OPENSSL_PROVIDER using X509PubKeyPointer = DeleteFnPtr; // OSSL_STORE_close() returns int, so it needs a void-returning adapter to be @@ -1594,6 +1603,164 @@ bool X509View::isCA() const { return X509_check_ca(const_cast(cert_)) == 1; } +namespace { +bool CopyStringBytes(const ASN1_STRING* value, + std::vector* output) { + if (value == nullptr) return false; + const int length = ASN1_STRING_length(value); + const unsigned char* data = ASN1_STRING_get0_data(value); + if (length < 0 || (length > 0 && data == nullptr)) return false; + if (length == 0) { + output->clear(); + } else { + output->assign(data, data + static_cast(length)); + } + return true; +} + +bool CanConvertNameValueToUtf8(const ASN1_STRING* value) { + if (value == nullptr) return false; + switch (ASN1_STRING_type(value)) { + case V_ASN1_UTF8STRING: + case V_ASN1_NUMERICSTRING: + case V_ASN1_PRINTABLESTRING: + case V_ASN1_T61STRING: + case V_ASN1_IA5STRING: + case V_ASN1_UTCTIME: + case V_ASN1_GENERALIZEDTIME: + case V_ASN1_ISO64STRING: + case V_ASN1_UNIVERSALSTRING: + case V_ASN1_BMPSTRING: + return true; + default: + return false; + } +} + +std::optional GetEncodedRdnCount(const X509_NAME* name) { + const unsigned char* der = nullptr; + size_t der_length = 0; + if (X509_NAME_get0_der(name, &der, &der_length) != 1 || der == nullptr || + der_length > static_cast(LONG_MAX)) { + return std::nullopt; + } + + const unsigned char* cursor = der; + ASN1SequencePointer rdns(d2i_ASN1_SEQUENCE_ANY( + nullptr, &cursor, static_cast(der_length))); // NOLINT(runtime/int) + if (!rdns || cursor != der + der_length) return std::nullopt; + + const OPENSSL_SIZE_T count = sk_ASN1_TYPE_num(rdns.get()); + for (OPENSSL_SIZE_T i = 0; i < count; i++) { + const ASN1_TYPE* rdn = sk_ASN1_TYPE_value(rdns.get(), i); + if (rdn == nullptr || ASN1_TYPE_get(rdn) != V_ASN1_SET) { + return std::nullopt; + } + } + return static_cast(count); +} + +std::optional InspectName(const X509_NAME* name) { + if (name == nullptr) return std::nullopt; + const int count = X509_NAME_entry_count(name); + if (count < 0) return std::nullopt; + const auto encoded_rdn_count = GetEncodedRdnCount(name); + if (!encoded_rdn_count.has_value()) return std::nullopt; + + X509NameInspection output; + output.reserve(static_cast(count)); + int previous_set = -1; + for (int i = 0; i < count; i++) { + const X509_NAME_ENTRY* entry = X509_NAME_get_entry(name, i); + if (entry == nullptr) return std::nullopt; + + const ASN1_OBJECT* object = X509_NAME_ENTRY_get_object(entry); + const ASN1_STRING* value = X509_NAME_ENTRY_get_data(entry); + auto oid = ObjectToText(object, true); + const int set = X509_NAME_ENTRY_set(entry); + if (!oid.has_value() || value == nullptr || set < 0 || + (set != previous_set && + (previous_set == INT_MAX || set != previous_set + 1))) { + return std::nullopt; + } + previous_set = set; + + X509NameAttribute attribute{ + .set = set, + .oid = std::move(oid.value()), + }; + if (CanConvertNameValueToUtf8(value)) { + unsigned char* data = nullptr; + const int length = ASN1_STRING_to_UTF8(&data, value); + DataPointer owned(data, length > 0 ? static_cast(length) : 0); + if (length < 0 || (length > 0 && !owned)) return std::nullopt; + attribute.value = + length == 0 ? std::string() + : std::string(reinterpret_cast(owned.get()), + static_cast(length)); + } + output.push_back(std::move(attribute)); + } + const size_t inspected_rdn_count = + previous_set < 0 ? 0 : static_cast(previous_set) + 1; + if (inspected_rdn_count != encoded_rdn_count.value()) return std::nullopt; + return output; +} + +std::optional> InspectExtensions( + const X509* certificate) { + if (certificate == nullptr) return std::nullopt; + const int count = X509_get_ext_count(certificate); + if (count < 0) return std::nullopt; + + std::vector output; + output.reserve(static_cast(count)); + for (int i = 0; i < count; i++) { + const X509_EXTENSION* extension = X509_get_ext(certificate, i); + if (extension == nullptr) return std::nullopt; + + auto* mutable_extension = const_cast(extension); + auto oid = ObjectToText(X509_EXTENSION_get_object(mutable_extension), true); + const ASN1_OCTET_STRING* value = X509_EXTENSION_get_data(mutable_extension); + if (!oid.has_value() || value == nullptr) return std::nullopt; + + X509RawExtension inspected{ + .oid = std::move(oid.value()), + .critical = X509_EXTENSION_get_critical(mutable_extension) != 0, + }; + if (!CopyStringBytes(value, &inspected.value)) return std::nullopt; + output.push_back(std::move(inspected)); + } + return output; +} +} // namespace + +std::optional X509View::getVersion() const { + MarkPopErrorOnReturn mark_pop_error_on_return; + if (cert_ == nullptr) return std::nullopt; + const long version = X509_get_version(cert_); // NOLINT(runtime/int) + if (version < 0 || version > 2) return std::nullopt; + return static_cast(version) + 1; +} + +std::optional X509View::inspectSubjectName() const { + MarkPopErrorOnReturn mark_pop_error_on_return; + if (cert_ == nullptr) return std::nullopt; + return InspectName(X509_get_subject_name(cert_)); +} + +std::optional X509View::inspectIssuerName() const { + MarkPopErrorOnReturn mark_pop_error_on_return; + if (cert_ == nullptr) return std::nullopt; + return InspectName(X509_get_issuer_name(cert_)); +} + +std::optional> X509View::inspectExtensions() + const { + MarkPopErrorOnReturn mark_pop_error_on_return; + return InspectExtensions(cert_); +} + bool X509View::isIssuedBy(const X509View& issuer) const { MarkPopErrorOnReturn mark_pop_error_on_return; if (cert_ == nullptr || issuer.cert_ == nullptr) return false; diff --git a/deps/ncrypto/ncrypto.h b/deps/ncrypto/ncrypto.h index e45807ec1796..9ebf3ad535a3 100644 --- a/deps/ncrypto/ncrypto.h +++ b/deps/ncrypto/ncrypto.h @@ -59,6 +59,15 @@ #define NCRYPTO_USE_LEGACY_KEY_TYPES NCRYPTO_USE_BORINGSSL +// OpenSSL 4 makes ASN1_STRING opaque and provides a checked BIT STRING length +// accessor. Earlier OpenSSL releases and pinned BoringSSL expose the decoded +// unused-bit count through ASN1_STRING::flags. +#if !NCRYPTO_USE_BORINGSSL && OPENSSL_VERSION_PREREQ(4, 0) +#define NCRYPTO_HAS_ASN1_BIT_STRING_GET_LENGTH 1 +#else +#define NCRYPTO_HAS_ASN1_BIT_STRING_GET_LENGTH 0 +#endif + #if NCRYPTO_USE_OPENSSL_PROVIDER #include #include @@ -1618,6 +1627,20 @@ class X509Name final { int total_; }; +struct X509NameAttribute final { + int set = 0; + std::string oid; + std::optional value; +}; + +using X509NameInspection = std::vector; + +struct X509RawExtension final { + std::string oid; + bool critical = false; + std::vector value; +}; + class X509View final { public: static X509View From(const SSLPointer& ssl); @@ -1655,6 +1678,11 @@ class X509View final { Result getPublicKey() const; StackOfASN1 getKeyUsage() const; + std::optional getVersion() const; + std::optional inspectSubjectName() const; + std::optional inspectIssuerName() const; + std::optional> inspectExtensions() const; + bool isCA() const; bool isIssuedBy(const X509View& other) const; bool checkPrivateKey(const EVPKeyPointer& pkey) const; diff --git a/doc/api/crypto.md b/doc/api/crypto.md index 769992815023..d588771d191f 100644 --- a/doc/api/crypto.md +++ b/doc/api/crypto.md @@ -3152,6 +3152,47 @@ added: v15.6.0 Checks whether the public key for this certificate is consistent with the given private key. +### `x509.extensions` + + + +* Type: {Object\[]} + * `oid` {string} The canonical dotted-decimal extension OID. + * `critical` {boolean} Whether the extension is critical. + * `value` {Buffer} The bytes contained in the extension's `extnValue` OCTET + STRING. The OCTET STRING tag and length are not included. + +One entry is returned for every extension, in the order in which it appears in +the certificate. Unknown extensions and repeated OIDs are included. The +contents of `value` are not decoded according to `oid`. A certificate with no +extensions produces an empty array. + +Unlike [`x509.subjectAltName`][], [`x509.infoAccess`][], and +[`x509.keyUsage`][] (Extended Key Usage), which decode or format selected +extension types, this property exposes raw extension contents. `value` contains +the exact extension value bytes, not a complete encoded `Extension` record. +The extension-specific syntax is not validated. + +Each access returns a new array containing new objects and `Buffer`s. Modifying +them does not affect the certificate or later accesses. + +For example, a Basic Constraints extension allowing a certificate to act as a +CA can appear as: + +```js +console.log(x509.extensions[0]); +// { +// oid: '2.5.29.19', +// critical: true, +// value: +// } +``` + +Accessing this property throws `ERR_CRYPTO_INVALID_X509_CERTIFICATE` if the +extensions cannot be inspected. + ### `x509.fingerprint` + +* Type: {Object} + * `raw` {Buffer} The complete DER-encoded `Name`. + * `rdns` {Array\} The relative distinguished name sequence. + * `oid` {string} The canonical dotted-decimal object identifier. + * `value` {string} A UTF-8 representation of the attribute value. This + property is omitted when the ASN.1 value type is not supported for text + conversion. + +The issuer distinguished name, also exposed as a formatted string by +[`x509.issuer`][]. The outer `rdns` array contains the relative distinguished +names in encoded order. Each inner array represents one relative distinguished +name and contains its attributes in encoded order. A valid empty name has an +empty `rdns` array. + +`rdns` is a decoded view and does not include ASN.1 value types. Use `raw` for a +lossless representation of the complete name, including attribute values whose +`value` property is omitted from `rdns`. + +Each access returns newly allocated objects, arrays, and a `Buffer`. +Modifying them does not affect the certificate or later accesses. + +For example: + +```js +console.log(x509.issuerName.rdns); +// [ +// [ { oid: '2.5.4.6', value: 'US' } ], +// [ { oid: '2.5.4.10', value: 'Example CA' } ] +// ] +``` + +Accessing this property throws `ERR_CRYPTO_INVALID_X509_CERTIFICATE` if the +issuer name cannot be inspected. + ### `x509.keyUsage` + +* Type: {Object} + * `raw` {Buffer} The complete DER-encoded `Name`. + * `rdns` {Array\} The relative distinguished name sequence. + * `oid` {string} The canonical dotted-decimal object identifier. + * `value` {string} A UTF-8 representation of the attribute value. This + property is omitted when the ASN.1 value type is not supported for text + conversion. + +The subject distinguished name, also exposed as a formatted string by +[`x509.subject`][]. The outer `rdns` array contains the relative distinguished +names in encoded order. Each inner array represents one relative distinguished +name and contains its attributes in encoded order. A valid empty name has an +empty `rdns` array. + +`rdns` is a decoded view and does not include ASN.1 value types. Use `raw` for a +lossless representation of the complete name, including attribute values whose +`value` property is omitted from `rdns`. + +Each access returns newly allocated objects, arrays, and a `Buffer`. +Modifying them does not affect the certificate or later accesses. + +For example, attributes in the same inner array belong to one multi-valued +relative distinguished name: + +```js +console.log(x509.subjectName.rdns); +// [ +// [ { oid: '2.5.4.6', value: 'US' } ], +// [ +// { oid: '2.5.4.11', value: 'Security' }, +// { oid: '2.5.4.10', value: 'Example Corp' } +// ], +// [ { oid: '2.5.4.3', value: 'example.com' } ] +// ] +``` + +Accessing this property throws `ERR_CRYPTO_INVALID_X509_CERTIFICATE` if the +subject name cannot be inspected. + +### `x509.tbs` + + + +* Type: {Buffer} + +Unlike [`x509.raw`][], which contains the whole certificate, this property +contains only the complete encoded `TBSCertificate`: the exact bytes covered +by the certificate's signature. They are copied from the certificate encoding, +not reconstructed from decoded fields. + +Each access returns a new `Buffer`. Modifying it does not affect the certificate +or later accesses. + +Accessing this property throws `ERR_CRYPTO_INVALID_X509_CERTIFICATE` if the +`TBSCertificate` cannot be inspected. + ### `x509.toJSON()` + +* Type: {Object} + * `algorithm` {Object} The certificate-level `signatureAlgorithm` paired with + the `signatureValue`. + * `oid` {string} The canonical dotted-decimal algorithm OID. + * `parameters` {Buffer} The complete DER-encoded ASN.1 parameters. This + property is omitted when the parameters are absent. + * `value` {Buffer} The signature octets contained in the certificate's + `signatureValue` BIT STRING. + +`algorithm.oid` identifies the same certificate-level algorithm as +[`x509.signatureAlgorithmOid`][]. In addition to the algorithm identification +provided by that property and [`x509.signatureAlgorithm`][], this property +exposes the algorithm parameters and signature bytes. + +The parameters are DER-encoded independently; the complete encoded +`AlgorithmIdentifier` and BIT STRING wrappers are not returned. Accessing this +property does not verify the signature. + +Each access returns new outer and `algorithm` objects, with new `Buffer`s for +`value` and `parameters` when present. Modifying them does not affect the +certificate or later accesses. + +For example, a certificate signed using RSA with SHA-256 can produce: + +```js +console.log(x509.signature); +// { +// algorithm: { +// oid: '1.2.840.113549.1.1.11', +// parameters: +// }, +// value: +// } +``` + +Accessing this property throws `ERR_CRYPTO_INVALID_X509_CERTIFICATE` if the +signature cannot be inspected. + ### `x509.signatureAlgorithm` + +* Type: {number} + +The certificate version: `1` for X.509 v1, `2` for X.509 v2, or `3` for X.509 +v3. A certificate with no encoded version field has version `1`. + ### `x509.verify(publicKey)` + +An X.509 certificate field could not be inspected. + ### `ERR_CRYPTO_JOB_INIT_FAILED` diff --git a/lib/internal/crypto/x509.js b/lib/internal/crypto/x509.js index cf1de4474756..68353718b098 100644 --- a/lib/internal/crypto/x509.js +++ b/lib/internal/crypto/x509.js @@ -46,6 +46,7 @@ const { const { codes: { + ERR_CRYPTO_INVALID_X509_CERTIFICATE, ERR_INVALID_ARG_TYPE, ERR_INVALID_ARG_VALUE, ERR_INVALID_THIS, @@ -133,22 +134,28 @@ const { fingerprint512: nativeFingerprint512, getIssuerCert: nativeGetIssuerCert, infoAccess: nativeInfoAccess, + issuerName: nativeIssuerName, issuer: nativeIssuer, + extensions: nativeExtensions, keyUsage: nativeKeyUsage, pem: nativePem, publicKey: nativePublicKey, raw: nativeRaw, serialNumber: nativeSerialNumber, + signature: nativeSignature, signatureAlgorithm: nativeSignatureAlgorithm, signatureAlgorithmOid: nativeSignatureAlgorithmOid, subject: nativeSubject, + subjectName: nativeSubjectName, subjectAltName: nativeSubjectAltName, toLegacy: nativeToLegacy, validFrom: nativeValidFrom, validFromDate: nativeValidFromDate, validTo: nativeValidTo, validToDate: nativeValidToDate, + version: nativeVersion, verify: nativeVerify, + tbs: nativeTbs, } = NativeX509Certificate.prototype; let getState; @@ -163,6 +170,16 @@ const { return state[index]; } + function getInspectionValue(certificate, getter) { + getState(certificate); + const value = FunctionPrototypeCall(getter, certificate); + if (value === undefined) { + throw new ERR_CRYPTO_INVALID_X509_CERTIFICATE( + 'the certificate cannot be inspected'); + } + return value; + } + class X509Certificate { constructor(buffer) { if (typeof buffer === 'string') @@ -294,6 +311,30 @@ const { this, kSlotSignatureAlgorithmOid, nativeSignatureAlgorithmOid); } + get version() { + return getInspectionValue(this, nativeVersion); + } + + get subjectName() { + return getInspectionValue(this, nativeSubjectName); + } + + get issuerName() { + return getInspectionValue(this, nativeIssuerName); + } + + get extensions() { + return getInspectionValue(this, nativeExtensions); + } + + get tbs() { + return getInspectionValue(this, nativeTbs); + } + + get signature() { + return getInspectionValue(this, nativeSignature); + } + get raw() { return getCachedValue(this, kSlotRaw, nativeRaw); } diff --git a/lib/internal/errors.js b/lib/internal/errors.js index 88c4102eee4f..e2ee2cdd340d 100644 --- a/lib/internal/errors.js +++ b/lib/internal/errors.js @@ -1194,6 +1194,8 @@ E('ERR_CRYPTO_INVALID_KEY_OBJECT_TYPE', 'Invalid key object type %s, expected %s.', TypeError); E('ERR_CRYPTO_INVALID_MAC', 'Invalid MAC: %s', TypeError); E('ERR_CRYPTO_INVALID_STATE', 'Invalid state for operation %s', Error); +E('ERR_CRYPTO_INVALID_X509_CERTIFICATE', + 'Invalid X.509 certificate: %s', Error); E('ERR_CRYPTO_KEM_NOT_SUPPORTED', 'KEM is not supported', Error); E('ERR_CRYPTO_MAC_FINALIZED', 'MAC already finalized', Error); E('ERR_CRYPTO_MAC_NOT_SUPPORTED', 'MAC is not supported', Error); diff --git a/src/crypto/crypto_x509.cc b/src/crypto/crypto_x509.cc index 3c208082f944..044fc55be774 100644 --- a/src/crypto/crypto_x509.cc +++ b/src/crypto/crypto_x509.cc @@ -28,6 +28,7 @@ using ncrypto::Digest; using ncrypto::MarkPopErrorOnReturn; using ncrypto::SSLPointer; using ncrypto::X509Name; +using ncrypto::X509NameInspection; using ncrypto::X509Pointer; using ncrypto::X509View; using v8::Array; @@ -375,6 +376,380 @@ MaybeLocal GetKeyUsage(Environment* env, const X509View& cert) { return Array::New(env->isolate(), vec.data(), vec.size()); } +bool SetValue(Local context, + Local object, + Local name, + Local value) { + return object->CreateDataProperty(context, name, value).FromMaybe(false); +} + +bool SetArrayValue(Local context, + Local array, + uint32_t index, + Local value) { + return array->CreateDataProperty(context, index, value).FromMaybe(false); +} + +MaybeLocal CopyBuffer(Environment* env, + ncrypto::Buffer value) { + const char* data = + value.len == 0 ? "" : reinterpret_cast(value.data); + return Buffer::Copy(env, data, value.len); +} + +MaybeLocal CopyBuffer(Environment* env, + const std::vector& value) { + return CopyBuffer(env, {.data = value.data(), .len = value.size()}); +} + +std::optional OidToText(const ASN1_OBJECT* object) { + if (object == nullptr) return std::nullopt; + const int length = OBJ_obj2txt(nullptr, 0, object, 1); + if (length <= 0 || length == std::numeric_limits::max()) { + return std::nullopt; + } + std::string output(static_cast(length) + 1, '\0'); + if (OBJ_obj2txt(output.data(), length + 1, object, 1) != length) { + return std::nullopt; + } + output.resize(static_cast(length)); + return output; +} + +struct SequenceSpan final { + ncrypto::Buffer encoded; + ncrypto::Buffer content; +}; + +bool ReadSequence(ncrypto::Buffer input, + SequenceSpan* out) { + if (out == nullptr || input.data == nullptr || input.len == 0 || + input.len > static_cast(LONG_MAX)) { + return false; + } + + const unsigned char* const start = input.data; + const unsigned char* content = start; + long content_length = 0; // NOLINT(runtime/int) + int tag = 0; + int tag_class = 0; + const int flags = + ASN1_get_object(&content, + &content_length, + &tag, + &tag_class, + static_cast(input.len)); // NOLINT(runtime/int) + constexpr int kIndefiniteLength = 0x01; + constexpr int kDecodeError = 0x80; + if ((flags & (kIndefiniteLength | kDecodeError)) != 0 || + (flags & V_ASN1_CONSTRUCTED) == 0 || tag != V_ASN1_SEQUENCE || + tag_class != V_ASN1_UNIVERSAL || content_length < 0) { + return false; + } + + const size_t header_length = static_cast(content - start); + const size_t length = static_cast(content_length); + if (header_length > input.len || length > input.len - header_length) { + return false; + } + *out = { + .encoded = {.data = start, .len = header_length + length}, + .content = {.data = content, .len = length}, + }; + return true; +} + +std::optional> GetExactTbs( + const X509CertificateSnapshot::DER& certificate) { + SequenceSpan outer; + const ncrypto::Buffer encoded{ + .data = certificate.data(), + .len = certificate.size(), + }; + if (!ReadSequence(encoded, &outer) || outer.encoded.len != encoded.len) { + return std::nullopt; + } + + SequenceSpan tbs; + if (!ReadSequence(outer.content, &tbs)) return std::nullopt; + return tbs.encoded; +} + +bool GetNameDer(const X509_NAME* name, + ncrypto::Buffer* encoded) { + if (name == nullptr || encoded == nullptr) return false; + const unsigned char* data = nullptr; + size_t length = 0; + if (X509_NAME_get0_der(name, &data, &length) != 1 || + (data == nullptr && length != 0)) { + return false; + } + *encoded = {.data = data, .len = length}; + return true; +} + +MaybeLocal CreateName(Environment* env, + const X509NameInspection& native, + const X509_NAME* name) { + Local context = env->context(); + size_t rdn_count = 0; + for (const auto& attribute : native) { + if (attribute.set < 0) return {}; + const size_t set = static_cast(attribute.set); + if (set == rdn_count) { + rdn_count++; + } else if (rdn_count == 0 || set != rdn_count - 1) { + return {}; + } + } + + if (rdn_count > static_cast(INT_MAX)) return {}; + Local rdns = Array::New(env->isolate(), static_cast(rdn_count)); + LocalVector groups(env->isolate()); + groups.reserve(rdn_count); + std::vector group_lengths(rdn_count, 0); + for (size_t i = 0; i < rdn_count; i++) { + Local group = Array::New(env->isolate()); + if (!SetArrayValue(context, rdns, i, group)) return {}; + groups.push_back(group); + } + + for (const auto& attribute : native) { + Local output = Object::New(env->isolate()); + if (!SetValue(context, + output, + env->oid_string(), + OneByteString(env->isolate(), attribute.oid))) { + return {}; + } + if (attribute.value.has_value()) { + Local value; + if (!ToV8Value(env, attribute.value.value()).ToLocal(&value) || + !SetValue(context, output, env->value_string(), value)) { + return {}; + } + } + const size_t rdn_index = static_cast(attribute.set); + if (!SetArrayValue( + context, groups[rdn_index], group_lengths[rdn_index]++, output)) { + return {}; + } + } + + ncrypto::Buffer encoded; + Local raw; + Local output = Object::New(env->isolate()); + if (!GetNameDer(name, &encoded) || !CopyBuffer(env, encoded).ToLocal(&raw) || + !SetValue(context, output, env->raw_string(), raw) || + !SetValue(context, output, env->rdns_string(), rdns)) { + return {}; + } + return output; +} + +MaybeLocal CreateRawExtensions( + Environment* env, + const std::vector& extensions) { + Local context = env->context(); + if (extensions.size() > static_cast(INT_MAX)) return {}; + Local output = + Array::New(env->isolate(), static_cast(extensions.size())); + for (size_t i = 0; i < extensions.size(); i++) { + const auto& extension = extensions[i]; + Local value = Object::New(env->isolate()); + Local bytes; + if (!CopyBuffer(env, extension.value).ToLocal(&bytes) || + !SetValue(context, + value, + env->oid_string(), + OneByteString(env->isolate(), extension.oid)) || + !SetValue(context, + value, + env->critical_string(), + Boolean::New(env->isolate(), extension.critical)) || + !SetValue(context, value, env->value_string(), bytes) || + !SetArrayValue(context, output, i, value)) { + return {}; + } + } + return output; +} + +MaybeLocal CreateAlgorithmIdentifier(Environment* env, + const X509_ALGOR* native) { + if (native == nullptr) return {}; + const ASN1_OBJECT* native_oid = nullptr; + int parameter_type = V_ASN1_UNDEF; + const void* parameter_value = nullptr; + X509_ALGOR_get0(&native_oid, ¶meter_type, ¶meter_value, native); + auto oid = OidToText(native_oid); + if (!oid.has_value()) return {}; + + Local context = env->context(); + Local output = Object::New(env->isolate()); + if (!SetValue(context, + output, + env->oid_string(), + OneByteString(env->isolate(), oid.value()))) { + return {}; + } + if (parameter_type != V_ASN1_UNDEF) { + ncrypto::DeleteFnPtr native_parameters( + ASN1_TYPE_new()); + if (!native_parameters || + ASN1_TYPE_set1( + native_parameters.get(), parameter_type, parameter_value) != 1) { + return {}; + } + const int length = i2d_ASN1_TYPE(native_parameters.get(), nullptr); + if (length <= 0) return {}; + std::vector encoded(static_cast(length)); + unsigned char* cursor = encoded.data(); + if (i2d_ASN1_TYPE(native_parameters.get(), &cursor) != length || + cursor != encoded.data() + encoded.size()) { + return {}; + } + + Local parameters; + if (!CopyBuffer(env, encoded).ToLocal(¶meters) || + !SetValue(context, output, env->parameters_string(), parameters)) { + return {}; + } + } + return output; +} + +MaybeLocal CreateSignature(Environment* env, + const X509View& certificate) { + const ASN1_BIT_STRING* native_signature = nullptr; + const X509_ALGOR* native_algorithm = nullptr; + X509_get0_signature(&native_signature, &native_algorithm, certificate.get()); + if (native_signature == nullptr || native_algorithm == nullptr || + ASN1_STRING_type(native_signature) != V_ASN1_BIT_STRING) { + return {}; + } + + const int native_length = ASN1_STRING_length(native_signature); + const unsigned char* native_data = ASN1_STRING_get0_data(native_signature); + if (native_length < 0 || (native_length > 0 && native_data == nullptr)) { + return {}; + } +#if NCRYPTO_HAS_ASN1_BIT_STRING_GET_LENGTH + size_t byte_length = 0; + int unused_bits = 0; + if (ASN1_BIT_STRING_get_length( + native_signature, &byte_length, &unused_bits) != 1 || + unused_bits != 0 || byte_length != static_cast(native_length)) { + return {}; + } +#elif NCRYPTO_USE_OPENSSL_PROVIDER || NCRYPTO_USE_BORINGSSL + if ((native_signature->flags & ASN1_STRING_FLAG_BITS_LEFT) != 0 && + (native_signature->flags & 0x07) != 0) { + return {}; + } +#else +#error "Unsupported crypto backend" +#endif + + const ncrypto::Buffer signature{ + .data = native_data, + .len = static_cast(native_length), + }; + + Local algorithm; + Local value; + if (!CreateAlgorithmIdentifier(env, native_algorithm).ToLocal(&algorithm) || + !CopyBuffer(env, signature).ToLocal(&value)) { + return {}; + } + + Local context = env->context(); + Local output = Object::New(env->isolate()); + if (!SetValue(context, output, env->algorithm_string(), algorithm) || + !SetValue(context, output, env->value_string(), value)) { + return {}; + } + return output; +} + +void Version(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + X509Certificate* certificate; + ASSIGN_OR_RETURN_UNWRAP(&certificate, args.This()); + const auto version = certificate->view().getVersion(); + if (!version.has_value()) return; + args.GetReturnValue().Set( + Integer::New(env->isolate(), static_cast(version.value()))); +} + +void StructuredName(const FunctionCallbackInfo& args, bool subject) { + Environment* env = Environment::GetCurrent(args); + X509Certificate* certificate; + ASSIGN_OR_RETURN_UNWRAP(&certificate, args.This()); + + const auto native = subject ? certificate->view().inspectSubjectName() + : certificate->view().inspectIssuerName(); + if (!native.has_value()) return; + + MarkPopErrorOnReturn mark_pop_error_on_return; + const X509_NAME* name = subject ? X509_get_subject_name(certificate->get()) + : X509_get_issuer_name(certificate->get()); + Local output; + if (!CreateName(env, native.value(), name).ToLocal(&output)) { + return; + } + args.GetReturnValue().Set(output); +} + +void SubjectName(const FunctionCallbackInfo& args) { + StructuredName(args, true); +} + +void IssuerName(const FunctionCallbackInfo& args) { + StructuredName(args, false); +} + +void Extensions(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + X509Certificate* certificate; + ASSIGN_OR_RETURN_UNWRAP(&certificate, args.This()); + + const auto extensions = certificate->view().inspectExtensions(); + Local output; + if (!extensions.has_value() || + !CreateRawExtensions(env, extensions.value()).ToLocal(&output)) { + return; + } + args.GetReturnValue().Set(output); +} + +void Tbs(const FunctionCallbackInfo& args) { + MarkPopErrorOnReturn mark_pop_error_on_return; + Environment* env = Environment::GetCurrent(args); + X509Certificate* certificate; + ASSIGN_OR_RETURN_UNWRAP(&certificate, args.This()); + + const auto tbs = GetExactTbs(certificate->snapshot_der()); + Local output; + if (!tbs.has_value() || !CopyBuffer(env, tbs.value()).ToLocal(&output)) { + return; + } + args.GetReturnValue().Set(output); +} + +void Signature(const FunctionCallbackInfo& args) { + MarkPopErrorOnReturn mark_pop_error_on_return; + Environment* env = Environment::GetCurrent(args); + X509Certificate* certificate; + ASSIGN_OR_RETURN_UNWRAP(&certificate, args.This()); + + Local output; + if (!CreateSignature(env, certificate->view()).ToLocal(&output)) { + return; + } + args.GetReturnValue().Set(output); +} + void Pem(const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); X509Certificate* cert; @@ -994,6 +1369,12 @@ Local X509Certificate::GetConstructorTemplate( SetProtoMethodNoSideEffect(isolate, tmpl, "serialNumber", SerialNumber); SetProtoMethodNoSideEffect(isolate, tmpl, "pem", Pem); SetProtoMethodNoSideEffect(isolate, tmpl, "raw", Der); + SetProtoMethodNoSideEffect(isolate, tmpl, "version", Version); + SetProtoMethodNoSideEffect(isolate, tmpl, "subjectName", SubjectName); + SetProtoMethodNoSideEffect(isolate, tmpl, "issuerName", IssuerName); + SetProtoMethodNoSideEffect(isolate, tmpl, "extensions", Extensions); + SetProtoMethodNoSideEffect(isolate, tmpl, "tbs", Tbs); + SetProtoMethodNoSideEffect(isolate, tmpl, "signature", Signature); SetProtoMethodNoSideEffect(isolate, tmpl, "publicKey", PublicKey); SetProtoMethodNoSideEffect(isolate, tmpl, "checkCA", CheckCA); SetProtoMethodNoSideEffect(isolate, tmpl, "checkHost", CheckHost); @@ -1356,6 +1737,12 @@ void X509Certificate::RegisterExternalReferences( registry->Register(SerialNumber); registry->Register(Pem); registry->Register(Der); + registry->Register(Version); + registry->Register(SubjectName); + registry->Register(IssuerName); + registry->Register(Extensions); + registry->Register(Tbs); + registry->Register(Signature); registry->Register(PublicKey); registry->Register(CheckCA); registry->Register(CheckHost); diff --git a/src/crypto/crypto_x509.h b/src/crypto/crypto_x509.h index 44714d1bcca2..a618fcac880a 100644 --- a/src/crypto/crypto_x509.h +++ b/src/crypto/crypto_x509.h @@ -124,6 +124,9 @@ class X509Certificate final : public BaseObject { inline ncrypto::X509View view() const { return *cert_; } inline X509* get() { return cert_->get(); } + inline const X509CertificateSnapshot::DER& snapshot_der() const { + return snapshot_->at(snapshot_index_); + } v8::MaybeLocal toObject(Environment* env); static v8::MaybeLocal toObject(Environment* env, diff --git a/src/env_properties.h b/src/env_properties.h index c4e3bc5c72a0..8fffc3b74d36 100644 --- a/src/env_properties.h +++ b/src/env_properties.h @@ -112,6 +112,7 @@ V(code_string, "code") \ V(config_string, "config") \ V(constants_string, "constants") \ + V(critical_string, "critical") \ V(cwd_string, "cwd") \ V(data_string, "data") \ V(database_string, "database") \ @@ -244,6 +245,7 @@ V(node_string, "node") \ V(object_string, "Object") \ V(ocsp_request_string, "OCSPRequest") \ + V(oid_string, "oid") \ V(ok_string, "ok") \ V(oncertcb_string, "oncertcb") \ V(onchange_string, "onchange") \ @@ -276,6 +278,7 @@ V(p256_string, "P-256") \ V(p384_string, "P-384") \ V(p521_string, "P-521") \ + V(parameters_string, "parameters") \ V(parse_error_string, "Parse Error") \ V(password_string, "password") \ V(path_string, "path") \ @@ -301,6 +304,8 @@ V(psk_string, "psk") \ V(public_exponent_string, "publicExponent") \ V(rate_string, "rate") \ + V(raw_string, "raw") \ + V(rdns_string, "rdns") \ V(read_host_object_string, "_readHostObject") \ V(readable_string, "readable") \ V(read_bigints_string, "readBigInts") \ diff --git a/test/cctest/test_node_crypto.cc b/test/cctest/test_node_crypto.cc index 45d5b7facc39..bed36b150154 100644 --- a/test/cctest/test_node_crypto.cc +++ b/test/cctest/test_node_crypto.cc @@ -1022,6 +1022,14 @@ TEST(NodeCrypto, X509FormattingPreservesUnrelatedErrors) { const ncrypto::BIOPointer subject = cert.view().getSubject(); ASSERT_TRUE(subject); EXPECT_EQ(ERR_peek_error(), expected); + EXPECT_TRUE(cert.view().getVersion().has_value()); + EXPECT_EQ(ERR_peek_error(), expected); + EXPECT_TRUE(cert.view().inspectSubjectName().has_value()); + EXPECT_EQ(ERR_peek_error(), expected); + EXPECT_TRUE(cert.view().inspectIssuerName().has_value()); + EXPECT_EQ(ERR_peek_error(), expected); + EXPECT_TRUE(cert.view().inspectExtensions().has_value()); + EXPECT_EQ(ERR_peek_error(), expected); ERR_clear_error(); } diff --git a/test/parallel/test-crypto-x509-chain-snapshot.js b/test/parallel/test-crypto-x509-chain-snapshot.js index 5a0f696da0f1..0b1d3c995af0 100644 --- a/test/parallel/test-crypto-x509-chain-snapshot.js +++ b/test/parallel/test-crypto-x509-chain-snapshot.js @@ -20,12 +20,46 @@ const issuer = new X509Certificate(fixtures.readKey('ca3-cert.pem')); function assertChain(actual) { assert(actual instanceof X509Certificate); assert.deepStrictEqual(actual.raw, leaf.raw); + assertInspection(actual, leaf); assert.strictEqual(actual.issuerCertificate, actual.issuerCertificate); assert(actual.issuerCertificate instanceof X509Certificate); assert.deepStrictEqual(actual.issuerCertificate.raw, issuer.raw); + assertInspection(actual.issuerCertificate, issuer); assert.strictEqual(actual.issuerCertificate.issuerCertificate, undefined); } +function assertInspection(actual, expected) { + assert.strictEqual(actual.version, expected.version); + + const subjectName = actual.subjectName; + const issuerName = actual.issuerName; + const extensions = actual.extensions; + const tbs = actual.tbs; + const signature = actual.signature; + + assert.deepStrictEqual(subjectName, expected.subjectName); + assert.deepStrictEqual(issuerName, expected.issuerName); + assert.deepStrictEqual(extensions, expected.extensions); + assert.deepStrictEqual(tbs, expected.tbs); + assert.deepStrictEqual(signature, expected.signature); + + subjectName.raw.fill(0); + issuerName.raw.fill(0); + for (const extension of extensions) extension.value.fill(0); + subjectName.rdns.length = 0; + issuerName.rdns.length = 0; + extensions.length = 0; + tbs.fill(0); + signature.value.fill(0); + signature.algorithm.parameters?.fill(0); + + assert.deepStrictEqual(actual.subjectName, expected.subjectName); + assert.deepStrictEqual(actual.issuerName, expected.issuerName); + assert.deepStrictEqual(actual.extensions, expected.extensions); + assert.deepStrictEqual(actual.tbs, expected.tbs); + assert.deepStrictEqual(actual.signature, expected.signature); +} + function legacyChainRaw(certificate) { const result = []; const seen = new Set(); diff --git a/test/parallel/test-crypto-x509-inspection.js b/test/parallel/test-crypto-x509-inspection.js new file mode 100644 index 000000000000..54ee7dae359a --- /dev/null +++ b/test/parallel/test-crypto-x509-inspection.js @@ -0,0 +1,281 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const { X509Certificate } = require('crypto'); +const { isBoringSSL } = require('../common/crypto'); +const fixtures = require('../common/fixtures'); + +function load(name) { + return new X509Certificate(fixtures.readKey(name)); +} + +function readDer(input, offset = 0) { + const start = offset; + const tag = input[offset++]; + let length = input[offset++]; + if ((length & 0x80) !== 0) { + const octets = length & 0x7f; + length = 0; + for (let i = 0; i < octets; i++) + length = (length * 256) + input[offset++]; + } + return { + tag, + start, + contentStart: offset, + end: offset + length, + }; +} + +function readChildren(input, parent) { + const children = []; + for (let offset = parent.contentStart; offset < parent.end;) { + const child = readDer(input, offset); + children.push(child); + offset = child.end; + } + return children; +} + +function encodeDer(tag, content) { + let length; + if (content.length < 128) { + length = Buffer.of(content.length); + } else { + const octets = []; + for (let value = content.length; value !== 0; value >>>= 8) + octets.unshift(value & 0xff); + length = Buffer.of(0x80 | octets.length, ...octets); + } + return Buffer.concat([Buffer.of(tag), length, content]); +} + +function rawElement(input, element) { + return input.subarray(element.start, element.end); +} + +function appendExtensions(raw, additions) { + const certificate = readDer(raw); + const certificateFields = readChildren(raw, certificate); + const tbs = certificateFields[0]; + const tbsFields = readChildren(raw, tbs); + const extensionIndex = tbsFields.findIndex(({ tag }) => tag === 0xa3); + assert.notStrictEqual(extensionIndex, -1); + + const wrapper = tbsFields[extensionIndex]; + const extensionSequence = readChildren(raw, wrapper)[0]; + const extensions = readChildren(raw, extensionSequence) + .map((element) => rawElement(raw, element)); + extensions.push(...additions); + + const newWrapper = encodeDer(0xa3, encodeDer( + 0x30, Buffer.concat(extensions))); + const newTbsFields = tbsFields.map((element, index) => + (index === extensionIndex ? newWrapper : rawElement(raw, element))); + const newTbs = encodeDer(0x30, Buffer.concat(newTbsFields)); + const newCertificateFields = certificateFields.map((element, index) => + (index === 0 ? newTbs : rawElement(raw, element))); + return encodeDer(0x30, Buffer.concat(newCertificateFields)); +} + +function replaceVersion(raw, value) { + const certificate = readDer(raw); + const certificateFields = readChildren(raw, certificate); + const tbs = certificateFields[0]; + const tbsFields = readChildren(raw, tbs); + assert.strictEqual(tbsFields[0].tag, 0xa0); + const version = encodeDer(0xa0, encodeDer(0x02, value)); + const newTbs = encodeDer(0x30, Buffer.concat(tbsFields.map( + (element, index) => (index === 0 ? version : rawElement(raw, element))))); + return encodeDer(0x30, Buffer.concat(certificateFields.map( + (element, index) => (index === 0 ? newTbs : rawElement(raw, element))))); +} + +function replaceSubject(raw, value) { + const certificate = readDer(raw); + const certificateFields = readChildren(raw, certificate); + const tbs = certificateFields[0]; + const tbsFields = readChildren(raw, tbs); + const fieldOffset = tbsFields[0].tag === 0xa0 ? 1 : 0; + const subjectIndex = fieldOffset + 4; + const newTbs = encodeDer(0x30, Buffer.concat(tbsFields.map( + (element, index) => + (index === subjectIndex ? value : rawElement(raw, element))))); + return encodeDer(0x30, Buffer.concat(certificateFields.map( + (element, index) => (index === 0 ? newTbs : rawElement(raw, element))))); +} + +const keyUsageExtension = Buffer.from( + '300b0603551d0f0404030205a0', 'hex'); +const unknownExtension = Buffer.from( + '300906032a030404020500', 'hex'); +const criticalUnknownExtension = Buffer.from( + '300c06032a03040101ff04020500', 'hex'); + +{ + const cert = load('agent1-cert.pem'); + const raw = cert.raw; + const certificate = readDer(raw); + const [tbs] = readChildren(raw, certificate); + const tbsFields = readChildren(raw, tbs); + const fieldOffset = tbsFields[0].tag === 0xa0 ? 1 : 0; + const issuer = tbsFields[fieldOffset + 2]; + const subject = tbsFields[fieldOffset + 4]; + + assert.strictEqual(cert.version, 3); + assert.strictEqual(cert.subjectName.rdns.at(-1)[0].oid, + '1.2.840.113549.1.9.1'); + assert.strictEqual(cert.subjectName.rdns.at(-1)[0].value, + 'ry@tinyclouds.org'); + assert.deepStrictEqual(cert.subjectName.raw, rawElement(raw, subject)); + assert.strictEqual(cert.issuerName.rdns.at(-2)[0].value, 'ca1'); + assert.deepStrictEqual(cert.issuerName.raw, rawElement(raw, issuer)); + + assert.deepStrictEqual(cert.extensions.map(({ oid, critical }) => ({ + oid, + critical, + })), [{ + oid: '1.3.6.1.5.5.7.1.1', + critical: false, + }]); + assert(cert.extensions[0].value.length > 0); + + assert.deepStrictEqual(cert.tbs, rawElement(raw, tbs)); + + assert.deepStrictEqual(cert.signature.algorithm, { + oid: '1.2.840.113549.1.1.11', + parameters: Buffer.from('0500', 'hex'), + }); + assert(cert.signature.value.length > 0); +} + +// Raw inspection preserves extension order, duplicates, criticality, and the +// exact bytes inside extnValue without interpreting the extension syntax. +{ + const source = load('agent1-cert.pem'); + const cert = new X509Certificate(appendExtensions(source.raw, [ + keyUsageExtension, + unknownExtension, + criticalUnknownExtension, + ])); + + assert.deepStrictEqual(cert.extensions.slice(-3), [ + { + oid: '2.5.29.15', + critical: false, + value: Buffer.from('030205a0', 'hex'), + }, + { + oid: '1.2.3.4', + critical: false, + value: Buffer.from('0500', 'hex'), + }, + { + oid: '1.2.3.4', + critical: true, + value: Buffer.from('0500', 'hex'), + }, + ]); +} + +// An empty Name is valid, but an RDN is a nonempty SET OF attributes. OpenSSL +// does not expose empty RDNs through X509_NAME entries, so inspection must not +// silently collapse this malformed Name into a valid empty one. +{ + const source = load('agent1-cert.pem'); + const emptyRdnName = encodeDer(0x30, encodeDer(0x31, Buffer.alloc(0))); + const malformed = replaceSubject(source.raw, emptyRdnName); + let cert; + try { + cert = new X509Certificate(malformed); + } catch (err) { + if (!isBoringSSL || err.code !== 'ERR_OSSL_PEM_NO_START_LINE') { + throw err; + } + } + if (cert !== undefined) { + assert.throws(() => cert.subjectName, { + code: 'ERR_CRYPTO_INVALID_X509_CERTIFICATE', + }); + } +} + +// Reject unsupported versions at whichever native parsing layer observes them +// first. Backends that accept the encoding must reject it before converting +// OpenSSL's long result to the public one-based version number. +{ + const source = load('agent1-cert.pem'); + let cert; + try { + cert = new X509Certificate(replaceVersion( + source.raw, Buffer.from('7fffffffffffffff', 'hex'))); + } catch (err) { + assert.strictEqual(err.code, isBoringSSL ? + 'ERR_OSSL_PEM_NO_START_LINE' : 'ERR_OSSL_X509_INVALID_VERSION'); + } + if (cert !== undefined) { + assert.throws(() => cert.version, { + code: 'ERR_CRYPTO_INVALID_X509_CERTIFICATE', + }); + } +} + +// Inspection uses the immutable native snapshot, not the mutable Buffer cached +// by the legacy raw getter. +{ + const cert = load('agent1-cert.pem'); + cert.raw.fill(0); + assert.strictEqual(cert.version, 3); + assert.strictEqual(cert.subjectName.rdns.at(-1)[0].value, + 'ry@tinyclouds.org'); +} + +// New structured getters return fresh defensive values. Existing legacy +// getters retain their current identity behavior in this backportable change. +{ + const cert = load('leaf-from-intermediate-cert.pem'); + const subjectName = cert.subjectName; + const subjectRaw = subjectName.raw; + const extensions = cert.extensions; + const tbs = cert.tbs; + const signature = cert.signature; + const parameters = signature.algorithm.parameters; + + subjectName.rdns.length = 0; + subjectRaw.fill(0); + extensions.length = 0; + tbs.fill(0); + signature.value.fill(0); + parameters.fill(0); + + assert(cert.subjectName.rdns.length > 0); + assert.notDeepStrictEqual(cert.subjectName.raw, subjectRaw); + assert(cert.extensions.length > 0); + assert.notStrictEqual(cert.tbs[0], 0); + assert.notStrictEqual(cert.signature.value[0], 0); + assert.notStrictEqual(cert.signature.algorithm.parameters[0], 0); + assert.strictEqual(cert.raw, cert.raw); +} + +for (const name of [ + 'version', + 'subjectName', + 'issuerName', + 'extensions', + 'tbs', + 'signature', +]) { + const descriptor = Object.getOwnPropertyDescriptor( + X509Certificate.prototype, name); + assert.strictEqual(typeof descriptor.get, 'function'); + assert.strictEqual(descriptor.set, undefined); + assert.strictEqual(descriptor.enumerable, false); + assert.strictEqual(descriptor.configurable, true); + assert.throws(() => descriptor.get.call({}), { + code: 'ERR_INVALID_THIS', + }); +} diff --git a/typings/internalBinding/crypto.d.ts b/typings/internalBinding/crypto.d.ts index 3d5a9fceb2e6..6082eb7010bc 100644 --- a/typings/internalBinding/crypto.d.ts +++ b/typings/internalBinding/crypto.d.ts @@ -723,6 +723,32 @@ declare namespace InternalCryptoBinding { getIssuer(): Buffer | null; } + interface X509NameAttribute { + oid: string; + value?: string; + } + + interface X509Name { + raw: Buffer; + rdns: X509NameAttribute[][]; + } + + interface X509RawExtension { + oid: string; + critical: boolean; + value: Buffer; + } + + interface X509AlgorithmIdentifier { + oid: string; + parameters?: Buffer; + } + + interface X509Signature { + algorithm: X509AlgorithmIdentifier; + value: Buffer; + } + interface X509CertificateHandle { subject(): string; subjectAltName(): string | undefined; @@ -740,6 +766,12 @@ declare namespace InternalCryptoBinding { serialNumber(): string; signatureAlgorithm(): string | undefined; signatureAlgorithmOid(): string | undefined; + version(): number | undefined; + subjectName(): X509Name | undefined; + issuerName(): X509Name | undefined; + extensions(): X509RawExtension[] | undefined; + tbs(): Buffer | undefined; + signature(): X509Signature | undefined; raw(): Buffer; publicKey(): KeyObjectHandle; pem(): string;