diff --git a/.github/sync-node-ncrypto.json b/.github/sync-node-ncrypto.json new file mode 100644 index 0000000..5e32169 --- /dev/null +++ b/.github/sync-node-ncrypto.json @@ -0,0 +1,3 @@ +{ + "node_commit": "8385efc01343a835e3a0efe05611f44272cbb413" +} diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index 8737917..b7367e6 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -25,8 +25,8 @@ jobs: macos: runs-on: macos-latest steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: bazel-contrib/setup-bazel@bbf8fe8b219f642c7f8bc673215f28eb1d9dec51 # v0.10.0 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: bazel-contrib/setup-bazel@c5acdfb288317d0b5c0bbd7a396a3dc868bb0f86 # 0.19.0 with: bazelisk-cache: true disk-cache: ${{ github.workflow }} @@ -45,8 +45,8 @@ jobs: cxx: clang++-18 runs-on: ubuntu-latest steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: bazel-contrib/setup-bazel@bbf8fe8b219f642c7f8bc673215f28eb1d9dec51 # v0.10.0 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: bazel-contrib/setup-bazel@c5acdfb288317d0b5c0bbd7a396a3dc868bb0f86 # 0.19.0 with: bazelisk-cache: true disk-cache: ${{ github.workflow }} diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index f82bae9..1c26323 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -10,7 +10,7 @@ jobs: commitlint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 100 - uses: wagoid/commitlint-github-action@b948419dd99f3fd78a6548d48f94e3df7f6bf3ed # v6.2.1 diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index 5546d0a..3239fe1 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -24,10 +24,10 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Run clang-format - uses: jidicula/clang-format-action@6cd220de46c89139a0365edae93eee8eb30ca8fe # v4.16.0 + uses: jidicula/clang-format-action@654a770daa28443dd111d133e4083e21c1075674 # v4.18.0 with: clang-format-version: '21' fallback-style: 'Google' diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index dd49a8f..5f227c3 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -28,9 +28,9 @@ jobs: runs-on: [macos-14, macos-15] runs-on: ${{matrix.runs-on}} steps: - - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: ccache - uses: hendrikmuhs/ccache-action@v1.2 + uses: hendrikmuhs/ccache-action@d62db5f07c26379fc4b4e0916f098a92573c3b03 # v1.2.23 with: key: ${{github.job}}-${{matrix.os}} - name: Prepare @@ -40,4 +40,4 @@ jobs: # Ref: https://docs.github.com/en/actions/using-github-hosted-runners/using-github-hosted-runners/about-github-hosted-runners run: cmake --build build -j=3 - name: Test - run: ctest --output-on-failure --test-dir build \ No newline at end of file + run: ctest --output-on-failure --test-dir build diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 57f8a91..7d455ef 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -13,4 +13,4 @@ jobs: release-please: runs-on: ubuntu-latest steps: - - uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4.4.0 + - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 diff --git a/.github/workflows/sync-node-ncrypto.yml b/.github/workflows/sync-node-ncrypto.yml new file mode 100644 index 0000000..f70119f --- /dev/null +++ b/.github/workflows/sync-node-ncrypto.yml @@ -0,0 +1,109 @@ +name: Sync Node ncrypto + +on: + workflow_dispatch: + inputs: + node_ref: + description: nodejs/node ref to sync from + required: true + default: main + base_node_ref: + description: Optional previous nodejs/node ref for bootstrap or recovery + required: false + default: '' + +permissions: + contents: write + pull-requests: write + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: main + fetch-depth: 0 + + - name: Sync from nodejs/node + id: sync + env: + NODE_REF: ${{ inputs.node_ref }} + BASE_NODE_REF: ${{ inputs.base_node_ref }} + run: | + python3 tools/sync-node-ncrypto.py \ + --node-ref "$NODE_REF" \ + --base-node-ref "$BASE_NODE_REF" + + - name: Stop when there are no changes + if: steps.sync.outputs.has_changes != 'true' + run: echo 'No ncrypto changes to sync.' + + - name: Commit sync branch + id: commit + if: steps.sync.outputs.has_changes == 'true' + run: | + branch='${{ steps.sync.outputs.branch_name }}' + git switch -c "$branch" + git fetch origin "$branch:refs/remotes/origin/$branch" || true + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add \ + .github/sync-node-ncrypto.json \ + include/ncrypto.h \ + src/engine.cpp \ + src/ncrypto.cpp + git commit \ + -m 'chore: sync ncrypto from nodejs/node' \ + -m 'Node-Base-Commit: ${{ steps.sync.outputs.base_sha }}' \ + -m 'Node-Target-Commit: ${{ steps.sync.outputs.target_sha }}' + git push --force-with-lease origin "$branch" + echo "branch=$branch" >> "$GITHUB_OUTPUT" + + - name: Prepare PR body + if: steps.sync.outputs.has_changes == 'true' + run: | + { + echo 'Syncs `deps/ncrypto` from `nodejs/node` into this repository.' + echo + echo '- Base node commit: `${{ steps.sync.outputs.base_sha }}`' + echo '- Target node commit: `${{ steps.sync.outputs.target_sha }}`' + echo '- Conflicts: `${{ steps.sync.outputs.has_conflicts }}`' + if [ '${{ steps.sync.outputs.has_conflicts }}' = 'true' ]; then + echo + echo 'This PR was opened as a draft because the 3-way merge produced conflicts:' + echo + printf '%s\n' '${{ steps.sync.outputs.conflicts }}' | sed 's/^/- `/' | sed 's/$/`/' + fi + } > "$RUNNER_TEMP/pr-body.md" + + - name: Open or update PR + if: steps.sync.outputs.has_changes == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + branch='${{ steps.commit.outputs.branch }}' + title='chore: sync ncrypto from nodejs/node' + existing_url="$(gh pr view "$branch" --json url --jq .url 2>/dev/null || true)" + if [ -n "$existing_url" ]; then + gh pr edit "$branch" --title "$title" --body-file "$RUNNER_TEMP/pr-body.md" + if [ '${{ steps.sync.outputs.has_conflicts }}' = 'true' ]; then + gh pr ready "$branch" --undo || true + else + gh pr ready "$branch" || true + fi + echo "$existing_url" + exit 0 + fi + + args=( + pr create + --base main + --head "$branch" + --title "$title" + --body-file "$RUNNER_TEMP/pr-body.md" + ) + if [ '${{ steps.sync.outputs.has_conflicts }}' = 'true' ]; then + args+=(--draft) + fi + gh "${args[@]}" diff --git a/.github/workflows/ubuntu.yml b/.github/workflows/ubuntu.yml index 3b26eb6..f4486ac 100644 --- a/.github/workflows/ubuntu.yml +++ b/.github/workflows/ubuntu.yml @@ -29,9 +29,9 @@ jobs: cxx: [g++-14] runs-on: ${{matrix.runs-on}} steps: - - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: ccache - uses: hendrikmuhs/ccache-action@v1.2 + uses: hendrikmuhs/ccache-action@d62db5f07c26379fc4b4e0916f098a92573c3b03 # v1.2.23 with: key: ${{github.job}}-${{matrix.os}}-{{matrix.shared}} - name: Setup Ninja @@ -52,10 +52,10 @@ jobs: OPENSSL_VERSION: "3.4.1" OPENSSL_DIR: "${{ github.workspace }}/openssl-install" steps: - - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Cache OpenSSL id: cache-openssl - uses: actions/cache@v4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ${{ env.OPENSSL_DIR }} key: openssl-${{ env.OPENSSL_VERSION }}-${{ runner.os }} @@ -69,7 +69,7 @@ jobs: make -j$(nproc) make install_sw - name: ccache - uses: hendrikmuhs/ccache-action@v1.2 + uses: hendrikmuhs/ccache-action@d62db5f07c26379fc4b4e0916f098a92573c3b03 # v1.2.23 with: key: ${{github.job}}-openssl - name: Setup dependencies @@ -93,10 +93,10 @@ jobs: OPENSSL_VERSION: "3.4.1" OPENSSL_DIR: "${{ github.workspace }}/openssl-install" steps: - - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Cache OpenSSL id: cache-openssl - uses: actions/cache@v4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ${{ env.OPENSSL_DIR }} key: openssl-${{ env.OPENSSL_VERSION }}-${{ runner.os }} @@ -110,7 +110,7 @@ jobs: make -j$(nproc) make install_sw - name: ccache - uses: hendrikmuhs/ccache-action@v1.2 + uses: hendrikmuhs/ccache-action@d62db5f07c26379fc4b4e0916f098a92573c3b03 # v1.2.23 with: key: ${{github.job}}-openssl-no-argon2 - name: Setup dependencies diff --git a/.github/workflows/visual-studio.yml b/.github/workflows/visual-studio.yml index 8e966a6..0eb78f8 100644 --- a/.github/workflows/visual-studio.yml +++ b/.github/workflows/visual-studio.yml @@ -30,9 +30,9 @@ jobs: - {gen: Visual Studio 17 2022, arch: x64, config: Release} - {gen: Visual Studio 17 2022, arch: x64, config: Debug} steps: - - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: ccache - uses: hendrikmuhs/ccache-action@v1.2 + uses: hendrikmuhs/ccache-action@d62db5f07c26379fc4b4e0916f098a92573c3b03 # v1.2.23 with: key: ${{github.job}}-${{matrix.os}}-${{matrix.config}} - name: Install Dependencies diff --git a/include/ncrypto.h b/include/ncrypto.h index 79b7298..4f499a9 100644 --- a/include/ncrypto.h +++ b/include/ncrypto.h @@ -899,6 +899,9 @@ class EVPKeyPointer final { DER, PEM, JWK, + RAW_PUBLIC, + RAW_PRIVATE, + RAW_SEED, }; enum class PKParseError { NOT_RECOGNIZED, NEED_PASSPHRASE, FAILED }; @@ -908,6 +911,7 @@ class EVPKeyPointer final { bool output_key_object = false; PKFormatType format = PKFormatType::DER; PKEncodingType type = PKEncodingType::PKCS8; + int ec_point_form = POINT_CONVERSION_UNCOMPRESSED; AsymmetricKeyEncodingConfig() = default; AsymmetricKeyEncodingConfig(bool output_key_object, PKFormatType format, @@ -1622,8 +1626,9 @@ int NoPasswordCallback(char* buf, int size, int rwflag, void* u); int PasswordCallback(char* buf, int size, int rwflag, void* u); -bool SafeX509SubjectAltNamePrint(const BIOPointer& out, X509_EXTENSION* ext); -bool SafeX509InfoAccessPrint(const BIOPointer& out, X509_EXTENSION* ext); +bool SafeX509SubjectAltNamePrint(const BIOPointer& out, + const X509_EXTENSION* ext); +bool SafeX509InfoAccessPrint(const BIOPointer& out, const X509_EXTENSION* ext); // ============================================================================ // SPKAC diff --git a/src/ncrypto.cpp b/src/ncrypto.cpp index ee0822a..5e450e2 100644 --- a/src/ncrypto.cpp +++ b/src/ncrypto.cpp @@ -15,10 +15,6 @@ #include #endif -#if OPENSSL_VERSION_NUMBER >= 0x30200000L -#include -#endif - #include #include #include @@ -28,6 +24,9 @@ #include #include #include +#if OPENSSL_VERSION_NUMBER >= 0x30200000L +#include +#endif #endif #if OPENSSL_WITH_PQC struct PQCMapping { @@ -819,11 +818,15 @@ bool PrintGeneralName(const BIOPointer& out, const GENERAL_NAME* gen) { // Note that the preferred name syntax (see RFCs 5280 and 1034) with // wildcards is a subset of what we consider "safe", so spec-compliant DNS // names will never need to be escaped. - PrintAltName(out, reinterpret_cast(name->data), name->length); + PrintAltName(out, + reinterpret_cast(ASN1_STRING_get0_data(name)), + ASN1_STRING_length(name)); } else if (gen->type == GEN_EMAIL) { ASN1_IA5STRING* name = gen->d.rfc822Name; BIO_write(out.get(), "email:", 6); - PrintAltName(out, reinterpret_cast(name->data), name->length); + PrintAltName(out, + reinterpret_cast(ASN1_STRING_get0_data(name)), + ASN1_STRING_length(name)); } else if (gen->type == GEN_URI) { ASN1_IA5STRING* name = gen->d.uniformResourceIdentifier; BIO_write(out.get(), "URI:", 4); @@ -831,7 +834,9 @@ bool PrintGeneralName(const BIOPointer& out, const GENERAL_NAME* gen) { // with a few exceptions, most notably URIs that contains commas (see // RFC 2396). In other words, most legitimate URIs will not require // escaping. - PrintAltName(out, reinterpret_cast(name->data), name->length); + PrintAltName(out, + reinterpret_cast(ASN1_STRING_get0_data(name)), + ASN1_STRING_length(name)); } else if (gen->type == GEN_DIRNAME) { // Earlier versions of Node.js used X509_NAME_oneline to print the X509_NAME // object. The format was non standard and should be avoided. The use of @@ -864,17 +869,18 @@ bool PrintGeneralName(const BIOPointer& out, const GENERAL_NAME* gen) { } else if (gen->type == GEN_IPADD) { BIO_printf(out.get(), "IP Address:"); const ASN1_OCTET_STRING* ip = gen->d.ip; - const unsigned char* b = ip->data; - if (ip->length == 4) { + const unsigned char* b = ASN1_STRING_get0_data(ip); + int ip_len = ASN1_STRING_length(ip); + if (ip_len == 4) { BIO_printf(out.get(), "%d.%d.%d.%d", b[0], b[1], b[2], b[3]); - } else if (ip->length == 16) { + } else if (ip_len == 16) { for (unsigned int j = 0; j < 8; j++) { uint16_t pair = (b[2 * j] << 8) | b[2 * j + 1]; BIO_printf(out.get(), (j == 0) ? "%X" : ":%X", pair); } } else { #if OPENSSL_VERSION_MAJOR >= 3 - BIO_printf(out.get(), "", ip->length); + BIO_printf(out.get(), "", ip_len); #else BIO_printf(out.get(), ""); #endif @@ -924,15 +930,15 @@ bool PrintGeneralName(const BIOPointer& out, const GENERAL_NAME* gen) { if (unicode) { auto name = gen->d.otherName->value->value.utf8string; PrintAltName(out, - reinterpret_cast(name->data), - name->length, + reinterpret_cast(ASN1_STRING_get0_data(name)), + ASN1_STRING_length(name), AltNameOption::UTF8, prefix); } else { auto name = gen->d.otherName->value->value.ia5string; PrintAltName(out, - reinterpret_cast(name->data), - name->length, + reinterpret_cast(ASN1_STRING_get0_data(name)), + ASN1_STRING_length(name), AltNameOption::NONE, prefix); } @@ -953,11 +959,14 @@ bool PrintGeneralName(const BIOPointer& out, const GENERAL_NAME* gen) { } } // namespace -bool SafeX509SubjectAltNamePrint(const BIOPointer& out, X509_EXTENSION* ext) { - auto ret = OBJ_obj2nid(X509_EXTENSION_get_object(ext)); +bool SafeX509SubjectAltNamePrint(const BIOPointer& out, + const X509_EXTENSION* ext) { + // const_cast needed for OpenSSL < 4.0 which lacks const-correctness + auto* mext = const_cast(ext); + auto ret = OBJ_obj2nid(X509_EXTENSION_get_object(mext)); if (ret != NID_subject_alt_name) return false; - GENERAL_NAMES* names = static_cast(X509V3_EXT_d2i(ext)); + GENERAL_NAMES* names = static_cast(X509V3_EXT_d2i(mext)); if (names == nullptr) return false; bool ok = true; @@ -976,12 +985,14 @@ bool SafeX509SubjectAltNamePrint(const BIOPointer& out, X509_EXTENSION* ext) { return ok; } -bool SafeX509InfoAccessPrint(const BIOPointer& out, X509_EXTENSION* ext) { - auto ret = OBJ_obj2nid(X509_EXTENSION_get_object(ext)); +bool SafeX509InfoAccessPrint(const BIOPointer& out, const X509_EXTENSION* ext) { + // const_cast needed for OpenSSL < 4.0 which lacks const-correctness + auto* mext = const_cast(ext); + auto ret = OBJ_obj2nid(X509_EXTENSION_get_object(mext)); if (ret != NID_info_access) return false; AUTHORITY_INFO_ACCESS* descs = - static_cast(X509V3_EXT_d2i(ext)); + static_cast(X509V3_EXT_d2i(mext)); if (descs == nullptr) return false; bool ok = true; @@ -1125,7 +1136,7 @@ BIOPointer X509View::getValidFrom() const { if (cert_ == nullptr) return {}; BIOPointer bio(BIO_new(BIO_s_mem())); if (!bio) return {}; - ASN1_TIME_print(bio.get(), X509_get_notBefore(cert_)); + ASN1_TIME_print(bio.get(), X509_get0_notBefore(cert_)); return bio; } @@ -1134,7 +1145,7 @@ BIOPointer X509View::getValidTo() const { if (cert_ == nullptr) return {}; BIOPointer bio(BIO_new(BIO_s_mem())); if (!bio) return {}; - ASN1_TIME_print(bio.get(), X509_get_notAfter(cert_)); + ASN1_TIME_print(bio.get(), X509_get0_notAfter(cert_)); return bio; } @@ -3679,8 +3690,38 @@ bool ECKeyPointer::setPublicKey(const ECPointPointer& pub) { bool ECKeyPointer::setPublicKeyRaw(const BignumPointer& x, const BignumPointer& y) { if (!key_) return false; - return EC_KEY_set_public_key_affine_coordinates( - key_.get(), x.get(), y.get()) == 1; + const EC_GROUP* group = EC_KEY_get0_group(key_.get()); + if (group == nullptr) return false; + + // For curves with cofactor h=1, use EC_POINT_oct2point + + // EC_KEY_set_public_key instead of EC_KEY_set_public_key_affine_coordinates. + // The latter internally calls EC_KEY_check_key() which performs a scalar + // multiplication (n*Q) for order validation — redundant when h=1 since every + // on-curve point already has order n. EC_POINT_oct2point validates the point + // is on the curve, which is sufficient. For curves with h!=1, fall back to + // the full check. + auto cofactor = BignumPointer::New(); + if (!cofactor || !EC_GROUP_get_cofactor(group, cofactor.get(), nullptr) || + !cofactor.isOne()) { + return EC_KEY_set_public_key_affine_coordinates( + key_.get(), x.get(), y.get()) == 1; + } + + // Field element byte length: ceil(degree_bits / 8). + size_t field_len = (EC_GROUP_get_degree(group) + 7) / 8; + // Build an uncompressed point: 0x04 || x || y, each padded to field_len. + size_t uncompressed_len = 1 + 2 * field_len; + auto buf = DataPointer::Alloc(uncompressed_len); + if (!buf) return false; + unsigned char* ptr = static_cast(buf.get()); + ptr[0] = POINT_CONVERSION_UNCOMPRESSED; + x.encodePaddedInto(ptr + 1, field_len); + y.encodePaddedInto(ptr + 1 + field_len, field_len); + + auto point = ECPointPointer::New(group); + if (!point) return false; + if (!point.setFromBuffer({ptr, uncompressed_len}, group)) return false; + return EC_KEY_set_public_key(key_.get(), point.get()) == 1; } bool ECKeyPointer::setPrivateKey(const BignumPointer& priv) { @@ -4502,6 +4543,27 @@ std::optional EVPMDCtxPointer::signInitWithContext( #ifdef OSSL_SIGNATURE_PARAM_CONTEXT_STRING EVP_PKEY_CTX* ctx = nullptr; +#ifdef OSSL_SIGNATURE_PARAM_INSTANCE + // Ed25519 requires the INSTANCE param to switch into Ed25519ctx mode. + // Without it, OpenSSL silently ignores the context string. + if (key.id() == EVP_PKEY_ED25519) { + const OSSL_PARAM params[] = { + OSSL_PARAM_construct_utf8_string( + OSSL_SIGNATURE_PARAM_INSTANCE, const_cast("Ed25519ctx"), 0), + OSSL_PARAM_construct_octet_string( + OSSL_SIGNATURE_PARAM_CONTEXT_STRING, + const_cast(context_string.data), + context_string.len), + OSSL_PARAM_END}; + + if (!EVP_DigestSignInit_ex( + ctx_.get(), &ctx, nullptr, nullptr, nullptr, key.get(), params)) { + return std::nullopt; + } + return ctx; + } +#endif // OSSL_SIGNATURE_PARAM_INSTANCE + const OSSL_PARAM params[] = { OSSL_PARAM_construct_octet_string( OSSL_SIGNATURE_PARAM_CONTEXT_STRING, @@ -4526,6 +4588,27 @@ std::optional EVPMDCtxPointer::verifyInitWithContext( #ifdef OSSL_SIGNATURE_PARAM_CONTEXT_STRING EVP_PKEY_CTX* ctx = nullptr; +#ifdef OSSL_SIGNATURE_PARAM_INSTANCE + // Ed25519 requires the INSTANCE param to switch into Ed25519ctx mode. + // Without it, OpenSSL silently ignores the context string. + if (key.id() == EVP_PKEY_ED25519) { + const OSSL_PARAM params[] = { + OSSL_PARAM_construct_utf8_string( + OSSL_SIGNATURE_PARAM_INSTANCE, const_cast("Ed25519ctx"), 0), + OSSL_PARAM_construct_octet_string( + OSSL_SIGNATURE_PARAM_CONTEXT_STRING, + const_cast(context_string.data), + context_string.len), + OSSL_PARAM_END}; + + if (!EVP_DigestVerifyInit_ex( + ctx_.get(), &ctx, nullptr, nullptr, nullptr, key.get(), params)) { + return std::nullopt; + } + return ctx; + } +#endif // OSSL_SIGNATURE_PARAM_INSTANCE + const OSSL_PARAM params[] = { OSSL_PARAM_construct_octet_string( OSSL_SIGNATURE_PARAM_CONTEXT_STRING, @@ -4824,12 +4907,12 @@ bool X509Name::Iterator::operator!=(const Iterator& other) const { std::pair X509Name::Iterator::operator*() const { if (loc_ == name_.total_) return {{}, {}}; - X509_NAME_ENTRY* entry = X509_NAME_get_entry(name_, loc_); + const X509_NAME_ENTRY* entry = X509_NAME_get_entry(name_, loc_); if (entry == nullptr) [[unlikely]] return {{}, {}}; - ASN1_OBJECT* name = X509_NAME_ENTRY_get_object(entry); - ASN1_STRING* value = X509_NAME_ENTRY_get_data(entry); + const ASN1_OBJECT* name = X509_NAME_ENTRY_get_object(entry); + const ASN1_STRING* value = X509_NAME_ENTRY_get_data(entry); if (name == nullptr || value == nullptr) [[unlikely]] { return {{}, {}}; diff --git a/tools/sync-node-ncrypto.py b/tools/sync-node-ncrypto.py new file mode 100644 index 0000000..5a461a3 --- /dev/null +++ b/tools/sync-node-ncrypto.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Sequence + + +NODE_REPOSITORY = 'https://github.com/nodejs/node.git' +STATE_FILE = Path('.github/sync-node-ncrypto.json') +MAPPINGS = { + 'deps/ncrypto/ncrypto.h': Path('include/ncrypto.h'), + 'deps/ncrypto/ncrypto.cc': Path('src/ncrypto.cpp'), + 'deps/ncrypto/engine.cc': Path('src/engine.cpp'), +} +SOURCE_SUFFIXES = ('.c', '.cc', '.cpp', '.cxx', '.h', '.hh', '.hpp', '.hxx') + + +class SyncError(Exception): + pass + + +def run( + args: Sequence[str], + *, + input_data: bytes | None = None, + check: bool = True, +) -> subprocess.CompletedProcess[bytes]: + result = subprocess.run(args, input=input_data, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + if check and result.returncode != 0: + command = ' '.join(args) + stderr = result.stderr.decode(errors='replace').strip() + raise SyncError(f'{command} failed: {stderr}') + return result + + +def git(args: Sequence[str], *, check: bool = True) -> subprocess.CompletedProcess[bytes]: + return run(('git', *args), check=check) + + +def repo_root() -> Path: + return Path(git(('rev-parse', '--show-toplevel')).stdout.decode().strip()) + + +def fetch_ref(repository: str, ref: str) -> str: + if not ref: + raise SyncError('ref cannot be empty') + + git(('fetch', '--no-tags', '--depth=1', repository, ref)) + return git(('rev-parse', 'FETCH_HEAD^{commit}')).stdout.decode().strip() + + +def load_state(path: Path) -> str | None: + if not path.exists(): + return None + + with path.open(encoding='utf-8') as file: + state = json.load(file) + + node_commit = state.get('node_commit') + if node_commit is not None and not isinstance(node_commit, str): + raise SyncError(f'{path} has an invalid node_commit value') + return node_commit or None + + +def write_state(path: Path, node_commit: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + state = {'node_commit': node_commit} + with path.open('w', encoding='utf-8') as file: + json.dump(state, file, indent=2) + file.write('\n') + + +def node_file(commit: str, path: str) -> bytes: + return git(('show', f'{commit}:{path}')).stdout + + +def node_ncrypto_files(commit: str) -> list[str]: + output = git(('ls-tree', '-r', '--name-only', commit, '--', 'deps/ncrypto')).stdout.decode() + return [line for line in output.splitlines() if line] + + +def check_unmapped_files(commit: str) -> None: + mapped = set(MAPPINGS) + unmapped = [ + path + for path in node_ncrypto_files(commit) + if path.endswith(SOURCE_SUFFIXES) and path not in mapped + ] + if unmapped: + files = '\n'.join(f'- {path}' for path in unmapped) + raise SyncError(f'nodejs/node added unmapped deps/ncrypto source/header files:\n{files}') + + +def mapped_files_different_from_node(commit: str) -> list[str]: + return [ + str(destination) + for source, destination in MAPPINGS.items() + if destination.read_bytes() != node_file(commit, source) + ] + + +def write_temp_file(directory: Path, name: str, data: bytes) -> Path: + path = directory / name + path.write_bytes(data) + return path + + +def merge_file( + *, + source: str, + destination: Path, + base_sha: str, + target_sha: str, + temporary_directory: Path, +) -> tuple[bytes, bool]: + base = write_temp_file(temporary_directory, 'base', node_file(base_sha, source)) + theirs = write_temp_file(temporary_directory, 'theirs', node_file(target_sha, source)) + ours = write_temp_file(temporary_directory, 'ours', destination.read_bytes()) + result = git( + ( + 'merge-file', + '-p', + '--diff3', + '-L', + f'nodejs/ncrypto:{destination}', + '-L', + f'nodejs/node:{source}@{base_sha[:12]}', + '-L', + f'nodejs/node:{source}@{target_sha[:12]}', + str(ours), + str(base), + str(theirs), + ), + check=False, + ) + if result.returncode >= 128: + stderr = result.stderr.decode(errors='replace').strip() + raise SyncError(f'failed to merge {source} into {destination}: {stderr}') + return result.stdout, result.returncode != 0 + + +def has_changes(paths: Sequence[Path]) -> bool: + result = git(('status', '--porcelain', '--', *(str(path) for path in paths))) + return bool(result.stdout.strip()) + + +def write_github_output(values: dict[str, str | bool | Sequence[str]]) -> None: + output_path = os.environ.get('GITHUB_OUTPUT') + if not output_path: + return + + with Path(output_path).open('a', encoding='utf-8') as file: + for key, value in values.items(): + if isinstance(value, bool): + file.write(f'{key}={str(value).lower()}\n') + elif isinstance(value, str): + file.write(f'{key}={value}\n') + else: + file.write(f'{key}< int: + root = repo_root() + os.chdir(root) + + state_path = Path(args.state_file) + current_state = load_state(state_path) + base_ref = args.base_node_ref or current_state + if base_ref is None: + raise SyncError(f'{state_path} does not record a node_commit; pass --base-node-ref to bootstrap the sync') + + base_sha = fetch_ref(args.node_repository, base_ref) + target_sha = fetch_ref(args.node_repository, args.node_ref) + + check_unmapped_files(target_sha) + + if current_state is None and base_sha == target_sha: + differing_files = mapped_files_different_from_node(target_sha) + if differing_files: + files = '\n'.join(f'- {path}' for path in differing_files) + raise SyncError( + 'refusing to bootstrap sync state from identical base and target Node commits because the mapped ' + f'standalone files differ from nodejs/node:\n{files}\n' + 'Pass the previous imported nodejs/node commit as --base-node-ref, not the target commit.' + ) + + conflicts: list[str] = [] + would_change = current_state != target_sha + with tempfile.TemporaryDirectory(prefix='sync-node-ncrypto-') as temporary_directory_name: + temporary_directory = Path(temporary_directory_name) + for source, destination in MAPPINGS.items(): + merged, conflicted = merge_file( + source=source, + destination=destination, + base_sha=base_sha, + target_sha=target_sha, + temporary_directory=temporary_directory, + ) + if destination.read_bytes() != merged: + would_change = True + if not args.dry_run: + destination.write_bytes(merged) + if conflicted: + conflicts.append(str(destination)) + + if not args.dry_run: + write_state(state_path, target_sha) + + paths = [*MAPPINGS.values(), state_path] + changed = would_change if args.dry_run else has_changes(paths) + outputs = { + 'base_sha': base_sha, + 'target_sha': target_sha, + 'target_short_sha': target_sha[:12], + 'has_changes': changed, + 'has_conflicts': bool(conflicts), + 'conflicts': conflicts, + 'branch_name': f'sync-node-ncrypto/{target_sha[:12]}', + } + write_github_output(outputs) + + print(f'Base node commit: {base_sha}') + print(f'Target node commit: {target_sha}') + print(f'Changed files: {str(changed).lower()}') + print(f'Conflicts: {str(bool(conflicts)).lower()}') + for path in conflicts: + print(f'Conflict: {path}') + + return 0 + + +def parse_args(argv: Sequence[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description='Sync nodejs/node deps/ncrypto into standalone ncrypto.') + parser.add_argument('--node-repository', default=NODE_REPOSITORY) + parser.add_argument('--node-ref', default='main') + parser.add_argument('--base-node-ref', default='') + parser.add_argument('--state-file', default=str(STATE_FILE)) + parser.add_argument('--dry-run', action='store_true') + return parser.parse_args(argv) + + +def main(argv: Sequence[str]) -> int: + try: + return sync(parse_args(argv)) + except SyncError as error: + print(f'error: {error}', file=sys.stderr) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main(sys.argv[1:]))