diff --git a/.github/workflows/test-and-release.yml b/.github/workflows/test-and-release.yml index e65dbc7..7e948f9 100644 --- a/.github/workflows/test-and-release.yml +++ b/.github/workflows/test-and-release.yml @@ -51,7 +51,7 @@ jobs: echo "matrix=[ '${{ github.event.client_payload.tag }}' ]" >> $GITHUB_OUTPUT echo "coverage=${{ github.event.client_payload.tag }}" >> $GITHUB_OUTPUT else - echo "matrix=[ 'v0.15.2', 'v0.16.2', '' ]" >> $GITHUB_OUTPUT + echo "matrix=[ 'v0.17.1', 'v0.18.1', '' ]" >> $GITHUB_OUTPUT echo "coverage=$(git remote show https://github.com/rnpgp/rnp | grep HEAD | cut -d' ' -f5)" >> $GITHUB_OUTPUT fi @@ -61,12 +61,12 @@ jobs: fail-fast: false matrix: os: [ 'ubuntu-latest', 'macos-latest' ] - ruby: [ '2.7', '3.0', '3.1', '3.2', 'head' ] + ruby: [ '3.1', '3.2', '3.3', '3.4', 'head' ] rnp: ${{ fromJson(needs.prepare.outputs.matrix) }} env: [ { COVERAGE: false } ] include: - os: 'ubuntu-latest' - ruby: '3.2' + ruby: '3.4' rnp: ${{ needs.prepare.outputs.coverage }} env: { COVERAGE: 'true' } @@ -75,8 +75,10 @@ jobs: steps: - name: Install packages Ubuntu if: matrix.os == 'ubuntu-latest' -# Already installed on GHA: build-essential libbz2-dev zlib1g-dev - run: sudo apt-get install cmake libbotan-2-dev libjson-c-dev +# build-essential is preinstalled on GHA; libbz2-dev/zlib1g-dev are not on ubuntu-24.04 + run: | + sudo apt-get install cmake libbotan-2-dev libjson-c-dev libbz2-dev zlib1g-dev + echo LD_LIBRARY_PATH="/usr/local/lib:$LD_LIBRARY_PATH" >> $GITHUB_ENV - name: Install packages MacOS if: matrix.os == 'macos-latest' @@ -84,7 +86,7 @@ jobs: run: brew install botan json-c - name: Checkout rnp - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: repository: rnpgp/rnp path: rnp @@ -92,7 +94,9 @@ jobs: ref: ${{ matrix.rnp }} - name: Configure rnp - run: cmake -B rnp/build -DBUILD_TESTING=OFF -DENABLE_DOC=OFF -DBUILD_SHARED_LIBS=ON rnp + # Homebrew's botan is Botan 3, which requires CRYPTO_BACKEND=botan3 + # (supported by rnp >= 0.17.1). + run: cmake -B rnp/build -DBUILD_TESTING=OFF -DENABLE_DOC=OFF -DBUILD_SHARED_LIBS=ON ${{ matrix.os == 'macos-latest' && '-DCRYPTO_BACKEND=botan3' || '' }} rnp - name: Build rnp run: cmake --build rnp/build @@ -101,7 +105,7 @@ jobs: run: sudo cmake --install rnp/build - name: Checkout ruby-rnp - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup Ruby and bundle uses: ruby/setup-ruby@v1 @@ -112,21 +116,31 @@ jobs: - name: Test run: bundle exec rake + - name: Upload coverage to Codecov + if: env.COVERAGE == 'true' + uses: codecov/codecov-action@v5 + with: + files: ./coverage/coverage.json + token: ${{ secrets.CODECOV_TOKEN }} + # no CODECOV_TOKEN secret exists yet; don't fail CI until one is added + # (the repo's protected main branch makes uploads require a token) + fail_ci_if_error: false + package-and-release: runs-on: ubuntu-latest if: contains(github.ref, 'refs/tags/v') needs: build-and-test steps: - name: Install packages Ubuntu -# Already installed on GHA: build-essential libbz2-dev zlib1g-dev - run: sudo apt-get install cmake libbotan-2-dev libjson-c-dev +# build-essential is preinstalled on GHA; libbz2-dev/zlib1g-dev are not on ubuntu-24.04 + run: sudo apt-get install cmake libbotan-2-dev libjson-c-dev libbz2-dev zlib1g-dev - name: Checkout rnp - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: repository: rnpgp/rnp path: rnp - ref: master + ref: main submodules: true - name: Configure rnp @@ -139,12 +153,12 @@ jobs: run: sudo cmake --install rnp/build - name: Checkout ruby-rnp - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup Ruby and bundle uses: ruby/setup-ruby@v1 with: - ruby-version: '3.2' + ruby-version: '3.4' bundler-cache: true - name: Package diff --git a/README.adoc b/README.adoc index 191918f..8d89c76 100644 --- a/README.adoc +++ b/README.adoc @@ -1,16 +1,21 @@ = Ruby RNP bindings image:https://github.com/rnpgp/ruby-rnp/actions/workflows/test-and-release.yml/badge.svg["Build Status", link="https://github.com/rnpgp/ruby-rnp/actions/workflows/test-and-release.yml"] -image:https://codecov.io/github/rnpgp/ruby-rnp/coverage.svg["Code Coverage", link="https://codecov.io/github/rnpgp/ruby-rnp?branch=master"] +image:https://codecov.io/gh/rnpgp/ruby-rnp/branch/main/graph/badge.svg["Code Coverage", link="https://codecov.io/gh/rnpgp/ruby-rnp"] The `rnp` gem provides Ruby bindings to the https://github.com/rnpgp/rnp[librnp OpenPGP library]. -Documentation is available on https://www.rubydoc.info/github/rnpgp/ruby-rnp/master/[RubyDoc]. +Documentation is available on https://www.rubydoc.info/github/rnpgp/ruby-rnp/main/[RubyDoc]. == Requirements -This gem currently requires https://github.com/rnpgp/rnp[librnp] 0.10.0 or newer. +This gem currently requires https://github.com/rnpgp/rnp[librnp] 0.15.2 or newer. + +NOTE: librnp 0.18.1 or newer is strongly recommended: librnp 0.18.0 is +affected by https://open.ribose.com/advisories/ra-2025-11-20/[CVE-2025-13470], +where PKESK session keys were generated without cryptographically random +values (older releases are not affected). == Installation diff --git a/docs/quickstart.adoc b/docs/quickstart.adoc index 23fd904..be1b62d 100644 --- a/docs/quickstart.adoc +++ b/docs/quickstart.adoc @@ -1,10 +1,14 @@ = RNP’s Ruby bindings overview -image:https://github.com/rnpgp/ruby-rnp/actions/workflows/tests.yml/badge.svg["Build Status", link="https://github.com/rnpgp/ruby-rnp/actions/workflows/tests.yml"] -image:https://codecov.io/github/rnpgp/ruby-rnp/coverage.svg["Code Coverage", link="https://codecov.io/github/rnpgp/ruby-rnp?branch=master"] +image:https://github.com/rnpgp/ruby-rnp/actions/workflows/test-and-release.yml/badge.svg["Build Status", link="https://github.com/rnpgp/ruby-rnp/actions/workflows/test-and-release.yml"] +image:https://codecov.io/gh/rnpgp/ruby-rnp/branch/main/graph/badge.svg["Code Coverage", link="https://codecov.io/gh/rnpgp/ruby-rnp"] The `rnp` gem provides Ruby bindings to the -https://www.rnpgp.com/software/rnp/[RNP OpenPGP library] and requires RNP 0.10.0 or newer. +https://www.rnpgp.com/software/rnp/[RNP OpenPGP library] and requires RNP 0.15.2 or newer. + +NOTE: RNP 0.18.1 or newer is strongly recommended: RNP 0.18.0 is affected by +CVE-2025-13470, where PKESK session keys were generated without +cryptographically random values (older releases are not affected). == Installation diff --git a/lib/rnp/ffi/librnp.rb b/lib/rnp/ffi/librnp.rb index 75ca511..85a2356 100644 --- a/lib/rnp/ffi/librnp.rb +++ b/lib/rnp/ffi/librnp.rb @@ -109,6 +109,9 @@ module LibRnp rnp_signature_packet_to_json: [%i[pointer uint32 pointer], :uint32], rnp_signature_handle_destroy: [%i[pointer], :uint32], rnp_op_verify_signature_get_handle: [%i[pointer pointer], :uint32], + # signature validation errors + rnp_signature_error_count: [%i[pointer pointer], :uint32], + rnp_signature_error_at: [%i[pointer size_t pointer], :uint32], # key uids rnp_key_get_uid_handle_at: [%i[pointer size_t pointer], :uint32], rnp_uid_is_revoked: [%i[pointer pointer], :uint32], diff --git a/lib/rnp/op/verify.rb b/lib/rnp/op/verify.rb index 706b416..3c5fd30 100644 --- a/lib/rnp/op/verify.rb +++ b/lib/rnp/op/verify.rb @@ -83,6 +83,8 @@ class Signature # @api private def initialize(ptr) + @ptr = ptr + # status @status = LibRnp.rnp_op_verify_signature_get_status(ptr) pptr = FFI::MemoryPointer.new(:pointer) @@ -133,6 +135,17 @@ def valid? def expired? @status == LibRnp::RNP_ERROR_SIGNATURE_EXPIRED end + + # Get the full signature handle, providing access to extended + # information (verification errors, JSON dump, etc). + # + # @see Rnp::Signature + # @return [Rnp::Signature] + def handle + pptr = FFI::MemoryPointer.new(:pointer) + Rnp.call_ffi(:rnp_op_verify_signature_get_handle, @ptr, pptr) + Rnp::Signature.new(pptr.read_pointer) + end end private diff --git a/lib/rnp/signature.rb b/lib/rnp/signature.rb index 6720e4d..f275f48 100644 --- a/lib/rnp/signature.rb +++ b/lib/rnp/signature.rb @@ -90,6 +90,23 @@ def json(mpi: false, raw: false, grip: false) end end + # Verification errors recorded for this signature. + # + # @note Requires librnp 0.18.0 or newer. + # + # @return [Array] a list of error codes (rnp_result_t values) + # describing why the signature failed verification. An empty array + # means no errors were recorded. + def errors + pcount = FFI::MemoryPointer.new(:size_t) + Rnp.call_ffi(:rnp_signature_error_count, @ptr, pcount) + perror = FFI::MemoryPointer.new(:uint32) + (0...pcount.read(:size_t)).map do |idx| + Rnp.call_ffi(:rnp_signature_error_at, @ptr, idx, perror) + perror.read(:uint32) + end + end + private def string_property(func) diff --git a/rakelib/platform.rake b/rakelib/platform.rake index ff5357e..b132350 100644 --- a/rakelib/platform.rake +++ b/rakelib/platform.rake @@ -20,22 +20,27 @@ def libname end end -def replace_in_file(file, old, new) - content = nil - File.open(file, "r") {|f| content = f.read} - File.open(file, "w") {|f| f.puts content.gsub(old, new)} -end - -def apply_workadound_1654 - # https://github.com/rnpgp/rnp/issues/1654 workaround - replace_in_file("ci/main.sh", '[ -v "GTEST_SOURCES" ]', - '[ -n "${GTEST_SOURCES:-}" ]') - replace_in_file("ci/main.sh", '[ -v "DOWNLOAD_GTEST" ]', - '[ -n "${DOWNLOAD_GTEST:-}" ]') - replace_in_file("ci/main.sh", '[ -v "DOWNLOAD_RUBYRNP" ]', - '[ -n "${DOWNLOAD_RUBYRNP:-}" ]') - replace_in_file("ci/main.sh", '[ -v "CRYPTO_BACKEND" ]', - '[ -n "${CRYPTO_BACKEND:-}" ]') +# rnp moved its CI shell scripts from ci/ to ci-legacy/ without updating +# their internal references, which still expect to be sourced as ci/... +# from the repository root. Recreate the expected layout with symlinks. +def setup_legacy_ci_scripts + { + "ci/main.sh" => "../ci-legacy/main.sh", + "ci/success.sh" => "../ci-legacy/success.sh", + "ci/env.inc.sh" => "../ci-legacy/env.inc.sh", + "ci/env-common.inc.sh" => "../ci-legacy/env-common.inc.sh", + "ci/env-linux.inc.sh" => "../ci-legacy/env-linux.inc.sh", + "ci/env-freebsd.inc.sh" => "../ci-legacy/env-freebsd.inc.sh", + "ci/utils.inc.sh" => "../ci-legacy/utils.inc.sh", + "ci/lib/install_functions.inc.sh" => + "../../ci-legacy/lib/install_functions.inc.sh", + "ci/lib/cacheable_install_functions.inc.sh" => + "../../ci-legacy/lib/cacheable_install_functions.inc.sh", + }.each do |link, target| + next if File.exist?(link) + FileUtils.mkdir_p(File.dirname(link)) + FileUtils.ln_s(target, link) + end end workspace = File.dirname(File.dirname(__FILE__)) @@ -73,7 +78,7 @@ end desc "Git clone rnp native library" task :rnp_git do - rev = ENV["RNP_VERSION"] || "master" + rev = ENV["RNP_VERSION"] || "main" unless Dir.exist?(librnp_path) system("git clone https://github.com/rnpgp/rnp -b #{rev} #{librnp_path}") end @@ -96,7 +101,6 @@ task compile: [:rnp_git] do "RNP_INSTALL" => rnp_install, "USE_STATIC_DEPENDENCIES" => "yes", "SKIP_TESTS" => "1", - "DOWNLOAD_RUBYRNP" => "OFF", } cache_path = File.join(workspace, "tmp", cache_dir) @@ -104,11 +108,12 @@ task compile: [:rnp_git] do FileUtils.ln_s(cache_path, tmp) Dir.chdir(librnp_path) do - apply_workadound_1654 + ci_dir = Dir.exist?("ci-legacy") ? "ci-legacy" : "ci" + setup_legacy_ci_scripts if ci_dir == "ci-legacy" - system(build_env, "ci/install_noncacheable_dependencies.sh") - system(build_env, "ci/install_cacheable_dependencies.sh botan jsonc") - system(build_env, "ci/run.sh") + system(build_env, "#{ci_dir}/install_noncacheable_dependencies.sh") + system(build_env, "#{ci_dir}/install_cacheable_dependencies.sh botan jsonc") + system(build_env, "#{ci_dir}/run.sh") end FileUtils.cp(File.join(rnp_install, "lib", libname), "lib/rnp/ffi/") diff --git a/rnp.gemspec b/rnp.gemspec index 05dbb99..e47af41 100644 --- a/rnp.gemspec +++ b/rnp.gemspec @@ -24,14 +24,14 @@ Gem::Specification.new do |spec| spec.metadata['yard.run'] = 'yard' spec.add_development_dependency 'asciidoctor', '~> 2.0' - spec.add_development_dependency 'bundler', '>= 1.14', '< 3' - spec.add_development_dependency 'codecov', '~> 0.1' - spec.add_development_dependency 'rake', '>= 10', '< 14' - spec.add_development_dependency 'redcarpet', '~> 3.4' - spec.add_development_dependency 'rspec', '~> 3.5' + spec.add_development_dependency 'bundler', '>= 2' + spec.add_development_dependency 'rake', '~> 13.0' + spec.add_development_dependency 'redcarpet', '~> 3.6' + spec.add_development_dependency 'rspec', '~> 3.13' spec.add_development_dependency 'rubocop', '~> 0.75.0' - spec.add_development_dependency 'simplecov', '~> 0.14' - spec.add_development_dependency 'yard', '~> 0.9.12' + spec.add_development_dependency 'simplecov', '~> 0.22' + spec.add_development_dependency 'simplecov-json', '~> 0.2' + spec.add_development_dependency 'yard', '~> 0.9' spec.add_runtime_dependency 'ffi', '~> 1.9' end diff --git a/spec/signature_spec.rb b/spec/signature_spec.rb new file mode 100644 index 0000000..62a46c6 --- /dev/null +++ b/spec/signature_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +# (c) 2026 Ribose Inc. + +require 'spec_helper' + +describe Rnp::Signature.instance_method(:errors), + skip: !LibRnp::HAVE_RNP_SIGNATURE_ERROR_COUNT do + let(:rnp) do + rnp = Rnp.new + rnp.load_keys(format: 'GPG', + input: Rnp::Input.from_path('spec/data/keyrings/gpg/secring.gpg')) + rnp.password_provider = 'password' + rnp + end + + let(:data) { 'data to sign' } + let(:signature) do + rnp.detached_sign(input: Rnp::Input.from_string(data), + signers: [rnp.find_key(userid: 'key0-uid1')], + hash: 'SHA256', + armored: false) + end + + def verify_errors(data, signature) + verify = rnp.start_detached_verify(data: Rnp::Input.from_string(data), + signature: Rnp::Input.from_string(signature)) + begin + verify.execute + rescue Rnp::InvalidSignatureError + # errors are still available via the signature handle + end + verify.signatures[0].handle.errors + end + + context 'valid signature' do + it 'has no errors' do + expect(verify_errors(data, signature)).to eql [] + end + + it 'provides a handle with extended information' do + verify = rnp.start_detached_verify(data: Rnp::Input.from_string(data), + signature: Rnp::Input.from_string(signature)) + verify.execute + handle = verify.signatures[0].handle + expect(handle).to be_a Rnp::Signature + expect(handle.type).to eql 'RSA' + expect(handle.errors).to eql [] + end + end + + context 'corrupted signature' do + let(:corrupted) do + raw = signature.dup + raw[-10] = (raw[-10].ord ^ 0xFF).chr + raw + end + + it 'has errors' do + expect(verify_errors(data, corrupted)) + .to eql [LibRnp::RNP_ERROR_SIGNATURE_INVALID] + end + end + + context 'key signature' do + let(:rnp) do + rnp = Rnp.new + rnp.load_keys( + format: 'GPG', + input: Rnp::Input.from_path('spec/data/keys/ecc-p384-pub.asc') + ) + rnp + end + + it 'has no errors' do + key = rnp.find_key(keyid: '242A3AA5EA85F44A') + expect(key.uids[0].signatures[0].errors).to eql [] + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index e6b8ecc..d61146b 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -4,10 +4,16 @@ if ENV["COVERAGE"] == "true" require "simplecov" - require "codecov" + require "simplecov-json" - SimpleCov.start - SimpleCov.formatter = SimpleCov::Formatter::Codecov + SimpleCov.start do + formatter SimpleCov::Formatter::MultiFormatter.new( + [ + SimpleCov::Formatter::HTMLFormatter, + SimpleCov::Formatter::JSONFormatter + ] + ) + end end $LOAD_PATH.unshift File.expand_path("../lib", __dir__)