From 24106a8a53bde9fec27eaceb76bd64668dd0402b Mon Sep 17 00:00:00 2001 From: Ronald Tse Date: Mon, 20 Jul 2026 01:34:00 +0800 Subject: [PATCH 01/14] Attach additional librnp FFI functions Attach (with optional-symbol probing) the librnp functions needed for verification/signature introspection, key certification, security profile rules, key inspection and misc utilities, along with the related constants. --- lib/rnp/ffi/librnp.rb | 160 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/lib/rnp/ffi/librnp.rb b/lib/rnp/ffi/librnp.rb index 85a2356..73c1bdf 100644 --- a/lib/rnp/ffi/librnp.rb +++ b/lib/rnp/ffi/librnp.rb @@ -127,6 +127,117 @@ module LibRnp # import rnp_import_keys: [%i[pointer pointer uint32 pointer], :uint32], rnp_import_signatures: [%i[pointer pointer uint32 pointer], :uint32], + # security profile + rnp_add_security_rule: [%i[pointer string string uint32 uint64 uint32], + :uint32], + rnp_get_security_rule: [%i[pointer string string uint64 pointer pointer + pointer], :uint32], + rnp_remove_security_rule: [%i[pointer string string uint32 uint32 uint64 + pointer], :uint32], + rnp_set_timestamp: [%i[pointer uint64], :uint32], + # crypto backend information + rnp_backend_string: [%i[], :string], + rnp_backend_version: [%i[], :string], + # password requesting via the ffi's password provider + rnp_request_password: [%i[pointer pointer string pointer], :uint32], + # secure buffer clearing + rnp_buffer_clear: [%i[pointer size_t], :void], + # verification flags and format + rnp_op_verify_set_flags: [%i[pointer uint32], :uint32], + rnp_op_verify_get_format: [%i[pointer pointer], :uint32], + # verification recipients + rnp_op_verify_get_recipient_count: [%i[pointer pointer], :uint32], + rnp_op_verify_get_used_recipient: [%i[pointer pointer], :uint32], + rnp_op_verify_get_recipient_at: [%i[pointer size_t pointer], :uint32], + rnp_recipient_get_keyid: [%i[pointer pointer], :uint32], + rnp_recipient_get_alg: [%i[pointer pointer], :uint32], + # verification password-based (symenc) entries + rnp_op_verify_get_symenc_count: [%i[pointer pointer], :uint32], + rnp_op_verify_get_used_symenc: [%i[pointer pointer], :uint32], + rnp_op_verify_get_symenc_at: [%i[pointer size_t pointer], :uint32], + rnp_symenc_get_cipher: [%i[pointer pointer], :uint32], + rnp_symenc_get_aead_alg: [%i[pointer pointer], :uint32], + rnp_symenc_get_hash_alg: [%i[pointer pointer], :uint32], + rnp_symenc_get_s2k_type: [%i[pointer pointer], :uint32], + rnp_symenc_get_s2k_iterations: [%i[pointer pointer], :uint32], + # signature properties + rnp_signature_get_type: [%i[pointer pointer], :uint32], + rnp_signature_get_expiration: [%i[pointer pointer], :uint32], + rnp_signature_get_features: [%i[pointer pointer], :uint32], + rnp_signature_get_key_flags: [%i[pointer pointer], :uint32], + rnp_signature_get_key_expiration: [%i[pointer pointer], :uint32], + rnp_signature_get_primary_uid: [%i[pointer pointer], :uint32], + rnp_signature_get_key_server: [%i[pointer pointer], :uint32], + rnp_signature_get_key_server_prefs: [%i[pointer pointer], :uint32], + rnp_signature_get_key_fprint: [%i[pointer pointer], :uint32], + rnp_signature_get_revoker: [%i[pointer pointer], :uint32], + rnp_signature_get_revocation_reason: [%i[pointer pointer pointer], :uint32], + rnp_signature_get_trust_level: [%i[pointer pointer pointer], :uint32], + rnp_signature_get_preferred_alg_count: [%i[pointer pointer], :uint32], + rnp_signature_get_preferred_alg: [%i[pointer size_t pointer], :uint32], + rnp_signature_get_preferred_hash_count: [%i[pointer pointer], :uint32], + rnp_signature_get_preferred_hash: [%i[pointer size_t pointer], :uint32], + rnp_signature_get_preferred_zalg_count: [%i[pointer pointer], :uint32], + rnp_signature_get_preferred_zalg: [%i[pointer size_t pointer], :uint32], + rnp_signature_is_valid: [%i[pointer uint32], :uint32], + rnp_signature_export: [%i[pointer pointer uint32], :uint32], + rnp_signature_remove: [%i[pointer pointer], :uint32], + # signature subpackets + rnp_signature_subpacket_count: [%i[pointer pointer], :uint32], + rnp_signature_subpacket_at: [%i[pointer size_t pointer], :uint32], + rnp_signature_subpacket_find: [%i[pointer uint8 bool size_t pointer], + :uint32], + rnp_signature_subpacket_info: [%i[pointer pointer pointer pointer], + :uint32], + rnp_signature_subpacket_data: [%i[pointer pointer pointer], :uint32], + rnp_signature_subpacket_destroy: [%i[pointer], :uint32], + # key signature creation/editing + rnp_key_certification_create: [%i[pointer pointer string pointer], :uint32], + rnp_key_direct_signature_create: [%i[pointer pointer pointer], :uint32], + rnp_key_revocation_signature_create: [%i[pointer pointer pointer], :uint32], + rnp_key_signature_set_hash: [%i[pointer string], :uint32], + rnp_key_signature_set_creation: [%i[pointer uint32], :uint32], + rnp_key_signature_set_key_flags: [%i[pointer uint32], :uint32], + rnp_key_signature_set_key_expiration: [%i[pointer uint32], :uint32], + rnp_key_signature_set_features: [%i[pointer uint32], :uint32], + rnp_key_signature_add_preferred_alg: [%i[pointer string], :uint32], + rnp_key_signature_add_preferred_hash: [%i[pointer string], :uint32], + rnp_key_signature_add_preferred_zalg: [%i[pointer string], :uint32], + rnp_key_signature_set_primary_uid: [%i[pointer bool], :uint32], + rnp_key_signature_set_key_server: [%i[pointer string], :uint32], + rnp_key_signature_set_key_server_prefs: [%i[pointer uint32], :uint32], + rnp_key_signature_set_revocation_reason: [%i[pointer string string], + :uint32], + rnp_key_signature_set_revoker: [%i[pointer pointer uint32], :uint32], + rnp_key_signature_set_trust_level: [%i[pointer uint8 uint8], :uint32], + rnp_key_signature_sign: [%i[pointer], :uint32], + rnp_key_remove_signatures: [%i[pointer uint32 pointer pointer], :uint32], + # key properties + rnp_key_get_version: [%i[pointer pointer], :uint32], + rnp_key_is_expired: [%i[pointer pointer], :uint32], + rnp_key_is_valid: [%i[pointer pointer], :uint32], + rnp_key_valid_till: [%i[pointer pointer], :uint32], + rnp_key_valid_till64: [%i[pointer pointer], :uint32], + rnp_key_get_revoker_count: [%i[pointer pointer], :uint32], + rnp_key_get_revoker_at: [%i[pointer size_t pointer], :uint32], + rnp_key_get_primary_fprint: [%i[pointer pointer], :uint32], + rnp_key_get_protection_type: [%i[pointer pointer], :uint32], + rnp_key_get_protection_mode: [%i[pointer pointer], :uint32], + rnp_key_get_protection_cipher: [%i[pointer pointer], :uint32], + rnp_key_get_protection_hash: [%i[pointer pointer], :uint32], + rnp_key_get_protection_iterations: [%i[pointer pointer], :uint32], + rnp_key_get_revocation_signature: [%i[pointer pointer], :uint32], + # key generation (op) + rnp_op_generate_set_request_password: [%i[pointer bool], :uint32], + # encryption flags + rnp_op_encrypt_set_flags: [%i[pointer uint32], :uint32], + # armor output + rnp_output_to_armor: [%i[pointer pointer string], :uint32], + rnp_output_armor_set_line_length: [%i[pointer size_t], :uint32], + # output finalization + rnp_output_finish: [%i[pointer], :uint32], + # packet dumping (human-readable) + rnp_dump_packets_to_output: [%i[pointer pointer uint32], :uint32], }.each do |name, signature| present = !ffi_libraries[0].find_function(name.to_s).nil? if !present @@ -461,7 +572,56 @@ def self.rnp_version_commit_timestamp RNP_JSON_DUMP_RAW = (1 << 1) RNP_JSON_DUMP_GRIP = (1 << 2) + RNP_DUMP_MPI = (1 << 0) + RNP_DUMP_RAW = (1 << 1) + RNP_DUMP_GRIP = (1 << 2) + + # predefined feature security levels + RNP_SECURITY_PROHIBITED = 0 + RNP_SECURITY_INSECURE = 1 + RNP_SECURITY_DEFAULT = 2 + + # flags for feature security rules + RNP_SECURITY_OVERRIDE = (1 << 0) + RNP_SECURITY_VERIFY_KEY = (1 << 1) + RNP_SECURITY_VERIFY_DATA = (1 << 2) + RNP_SECURITY_REMOVE_ALL = (1 << 16) + + # encryption flags + RNP_ENCRYPT_NOWRAP = (1 << 0) + + # decryption/verification flags + RNP_VERIFY_IGNORE_SIGS_ON_DECRYPT = (1 << 0) + RNP_VERIFY_REQUIRE_ALL_SIGS = (1 << 1) + RNP_VERIFY_ALLOW_HIDDEN_RECIPIENT = (1 << 2) + + # revocation key flags + RNP_REVOKER_SENSITIVE = (1 << 0) + + # key feature flags + RNP_KEY_FEATURE_MDC = (1 << 0) + RNP_KEY_FEATURE_AEAD = (1 << 1) + RNP_KEY_FEATURE_V5 = (1 << 2) + + # key usage flags + RNP_KEY_USAGE_CERTIFY = (1 << 0) + RNP_KEY_USAGE_SIGN = (1 << 1) + RNP_KEY_USAGE_ENCRYPT_COMMS = (1 << 2) + RNP_KEY_USAGE_ENCRYPT_STORAGE = (1 << 3) + + # key server preferences flags + RNP_KEY_SERVER_NO_MODIFY = (1 << 7) + + # signature validation flags + RNP_SIGNATURE_REVALIDATE = (1 << 0) + + # flags for rnp_key_remove_signatures + RNP_KEY_SIGNATURE_INVALID = (1 << 0) + RNP_KEY_SIGNATURE_UNKNOWN_KEY = (1 << 1) + RNP_KEY_SIGNATURE_NON_SELF_SIG = (1 << 2) + RNP_SUCCESS = 0 + RNP_ERROR_NOT_FOUND = 0x10000008 RNP_ERROR_BAD_FORMAT = 0x10000001 RNP_ERROR_SIGNATURE_INVALID = 0x12000002 RNP_ERROR_BAD_PASSWORD = 0x12000004 From fea5f4fa444c26f72e5ae2fe6687ddf7c207dea6 Mon Sep 17 00:00:00 2001 From: Ronald Tse Date: Mon, 20 Jul 2026 01:34:14 +0800 Subject: [PATCH 02/14] Add verification and signature introspection APIs Verify op: flags= (RNP_VERIFY_*), format (literal data format), recipient and symenc enumeration (recipients, used_recipient, symencs, used_symenc) via the new Recipient and Symenc value objects. Signature: signature_type, fingerprint, expiration_time, features, key_flags, key_expiration, primary_uid?, key_server(+prefs), revoker, revocation_reason, trust_level, preferred_ciphers/hashes/compressions, valid?, export, and the subpacket API (each_subpacket, subpackets, subpacket) via the new Signature::Subpacket value object. --- lib/rnp/op/verify.rb | 145 ++++++++++++++++ lib/rnp/signature.rb | 280 ++++++++++++++++++++++++++++++ spec/signature_spec.rb | 206 ++++++++++++++++++++++ spec/verify/introspection_spec.rb | 156 +++++++++++++++++ 4 files changed, 787 insertions(+) create mode 100644 spec/verify/introspection_spec.rb diff --git a/lib/rnp/op/verify.rb b/lib/rnp/op/verify.rb index 3c5fd30..7490be3 100644 --- a/lib/rnp/op/verify.rb +++ b/lib/rnp/op/verify.rb @@ -41,6 +41,80 @@ def execute Rnp.call_ffi(:rnp_op_verify_execute, @ptr) end + # Set flags which control the data verification/decryption process. + # + # @note All flags are set at once: flags not present in a subsequent + # call will be unset. + # + # @param flags [Integer] OR-ed combination of the LibRnp::RNP_VERIFY_* + # constants + # @return [void] + def flags=(flags) + Rnp.call_ffi(:rnp_op_verify_set_flags, @ptr, flags) + end + + # Get the format of the literal data stored in the message, if available. + # + # @return [String, nil] the single-character format ('b' for binary, + # 't' for text, 'u' for UTF-8 text, 'l' for local) or nil if this + # information is not available. + def format + pformat = FFI::MemoryPointer.new(:uint8) + Rnp.call_ffi(:rnp_op_verify_get_format, @ptr, pformat) + format = pformat.read(:uint8) + format.zero? ? nil : format.chr + end + + # Get a list of recipients (public keys) the message was encrypted to. + # + # @return [Array] + def recipients + pcount = FFI::MemoryPointer.new(:size_t) + Rnp.call_ffi(:rnp_op_verify_get_recipient_count, @ptr, pcount) + pptr = FFI::MemoryPointer.new(:pointer) + (0...pcount.read(:size_t)).map do |idx| + Rnp.call_ffi(:rnp_op_verify_get_recipient_at, @ptr, idx, pptr) + Recipient.new(pptr.read_pointer) + end + end + + # Get the recipient whose key was used to decrypt the message. + # + # @return [Recipient, nil] nil if the message was not decrypted with + # a public key (or not decrypted at all). + def used_recipient + pptr = FFI::MemoryPointer.new(:pointer) + Rnp.call_ffi(:rnp_op_verify_get_used_recipient, @ptr, pptr) + precipient = pptr.read_pointer + Recipient.new(precipient) unless precipient.null? + end + + # Get a list of password-based (symenc) entries the message was + # encrypted to. + # + # @return [Array] + def symencs + pcount = FFI::MemoryPointer.new(:size_t) + Rnp.call_ffi(:rnp_op_verify_get_symenc_count, @ptr, pcount) + pptr = FFI::MemoryPointer.new(:pointer) + (0...pcount.read(:size_t)).map do |idx| + Rnp.call_ffi(:rnp_op_verify_get_symenc_at, @ptr, idx, pptr) + Symenc.new(pptr.read_pointer) + end + end + + # Get the password-based (symenc) entry that was used to decrypt the + # message. + # + # @return [Symenc, nil] nil if the message was not decrypted with + # a password (or not decrypted at all). + def used_symenc + pptr = FFI::MemoryPointer.new(:pointer) + Rnp.call_ffi(:rnp_op_verify_get_used_symenc, @ptr, pptr) + psymenc = pptr.read_pointer + Symenc.new(psymenc) unless psymenc.null? + end + # Check if all signatures are good. # # @return [Boolean] true if all signatures are valid and not expired. @@ -148,6 +222,77 @@ def handle end end + # Class representing a public-key recipient of an encrypted message. + # + # This is a value object: the information is copied out of the + # verification operation when the object is created. + class Recipient + # The keyid of the key the message was encrypted to. This may be all + # zeroes for a hidden recipient. + # @return [String] + attr_reader :keyid + # The public key algorithm used to encrypt to this recipient. + # @return [String] + attr_reader :alg + + # @api private + def initialize(ptr) + raise Rnp::Error, 'NULL pointer' if ptr.null? + @keyid = Recipient.string_property(:rnp_recipient_get_keyid, ptr) + @alg = Recipient.string_property(:rnp_recipient_get_alg, ptr) + end + + # @api private + def self.string_property(func, ptr) + pptr = FFI::MemoryPointer.new(:pointer) + Rnp.call_ffi(func, ptr, pptr) + begin + pvalue = pptr.read_pointer + pvalue.read_string unless pvalue.null? + ensure + LibRnp.rnp_buffer_destroy(pvalue) + end + end + end + + # Class representing a password-based (symenc) entry of an encrypted + # message. + # + # This is a value object: the information is copied out of the + # verification operation when the object is created. + class Symenc + # The cipher used to encrypt the data encryption key (or the whole + # message). + # @return [String] + attr_reader :cipher + # The AEAD algorithm used, or 'None'. + # @return [String] + attr_reader :aead_alg + # The hash algorithm used to derive the key from the password. + # @return [String] + attr_reader :hash_alg + # The string-to-key type ('Simple', 'Salted' or + # 'Iterated and salted'). + # @return [String] + attr_reader :s2k_type + # The number of iterations for an iterated-and-salted s2k + # (0 otherwise). + # @return [Integer] + attr_reader :s2k_iterations + + # @api private + def initialize(ptr) + raise Rnp::Error, 'NULL pointer' if ptr.null? + @cipher = Recipient.string_property(:rnp_symenc_get_cipher, ptr) + @aead_alg = Recipient.string_property(:rnp_symenc_get_aead_alg, ptr) + @hash_alg = Recipient.string_property(:rnp_symenc_get_hash_alg, ptr) + @s2k_type = Recipient.string_property(:rnp_symenc_get_s2k_type, ptr) + piterations = FFI::MemoryPointer.new(:uint32) + Rnp.call_ffi(:rnp_symenc_get_s2k_iterations, ptr, piterations) + @s2k_iterations = piterations.read(:uint32) + end + end + private def signature_count diff --git a/lib/rnp/signature.rb b/lib/rnp/signature.rb index f275f48..9218e34 100644 --- a/lib/rnp/signature.rb +++ b/lib/rnp/signature.rb @@ -36,6 +36,14 @@ def type string_property(:rnp_signature_get_alg) end + # The signature type ('binary', 'text', 'certification (generic)', + # 'subkey binding', etc). + # + # @return [String] + def signature_type + string_property(:rnp_signature_get_type) + end + # The hash algorithm used in the signature. # # @return [String] @@ -50,6 +58,13 @@ def keyid string_property(:rnp_signature_get_keyid) end + # The signer's key fingerprint. + # + # @return [String, nil] nil if not available in the signature + def fingerprint + string_property(:rnp_signature_get_key_fprint) + end + # The time this signature was created at. # # @return [Time] @@ -59,6 +74,130 @@ def creation_time Time.at(pcreation.read(:uint32)) end + # The expiration time of the signature, as the number of seconds after + # the creation time (0 if the signature never expires). + # + # @return [Integer] + def expiration_time + pexpiration = FFI::MemoryPointer.new(:uint32) + Rnp.call_ffi(:rnp_signature_get_expiration, @ptr, pexpiration) + pexpiration.read(:uint32) + end + + # The key features stored in the signature, as OR-ed + # LibRnp::RNP_KEY_FEATURE_* constants (0 if not available). + # + # @return [Integer] + def features + uint32_property(:rnp_signature_get_features) + end + + # The key usage flags stored in the signature, as OR-ed + # LibRnp::RNP_KEY_USAGE_* constants (0 if not available). + # + # @return [Integer] + def key_flags + uint32_property(:rnp_signature_get_key_flags) + end + + # The key expiration time stored in the signature, as the number of + # seconds since the key creation time (0 if the key never expires). + # + # @return [Integer] + def key_expiration + uint32_property(:rnp_signature_get_key_expiration) + end + + # Whether the signature indicates that the corresponding userid should + # be considered as the primary one. + # + # @return [Boolean] + def primary_uid? + pprimary = FFI::MemoryPointer.new(:bool) + Rnp.call_ffi(:rnp_signature_get_primary_uid, @ptr, pprimary) + pprimary.read(:bool) + end + + # The key server URL stored in the signature. + # + # @return [String] empty if not present in the signature + def key_server + string_property(:rnp_signature_get_key_server) + end + + # The key server preferences flags stored in the signature + # (LibRnp::RNP_KEY_SERVER_* constants). + # + # @return [Integer] + def key_server_prefs + uint32_property(:rnp_signature_get_key_server_prefs) + end + + # The fingerprint of the designated revocation key, if available. + # + # @return [String] empty if not present in the signature + def revoker + string_property(:rnp_signature_get_revoker) + end + + # The revocation reason data stored in the signature. + # + # @return [Hash] a hash with :code and :reason keys. + # Values are empty strings when not available in the signature. + def revocation_reason + pcode = FFI::MemoryPointer.new(:pointer) + preason = FFI::MemoryPointer.new(:pointer) + Rnp.call_ffi(:rnp_signature_get_revocation_reason, @ptr, pcode, preason) + begin + pvalue = pcode.read_pointer + code = pvalue.read_string unless pvalue.null? + pvalue = preason.read_pointer + reason = pvalue.read_string unless pvalue.null? + { code: code, reason: reason } + ensure + LibRnp.rnp_buffer_destroy(pcode.read_pointer) + LibRnp.rnp_buffer_destroy(preason.read_pointer) + end + end + + # The trust level and amount stored in the signature. + # + # @return [Hash] a hash with :level and :amount keys + # (0 when not available in the signature). + def trust_level + plevel = FFI::MemoryPointer.new(:uint8) + pamount = FFI::MemoryPointer.new(:uint8) + Rnp.call_ffi(:rnp_signature_get_trust_level, @ptr, plevel, pamount) + { level: plevel.read(:uint8), amount: pamount.read(:uint8) } + end + + # The preferred symmetric algorithms listed in the signature + # (self-signatures only). + # + # @return [Array] + def preferred_ciphers + preferred_algs(:rnp_signature_get_preferred_alg_count, + :rnp_signature_get_preferred_alg) + end + + # The preferred hash algorithms listed in the signature + # (self-signatures only). + # + # @return [Array] + def preferred_hashes + preferred_algs(:rnp_signature_get_preferred_hash_count, + :rnp_signature_get_preferred_hash) + end + + # The preferred compression algorithms listed in the signature + # (self-signatures only). + # + # @return [Array] + def preferred_compressions + preferred_algs(:rnp_signature_get_preferred_zalg_count, + :rnp_signature_get_preferred_zalg) + end + # The signer's key. # # @return [Key] @@ -69,6 +208,115 @@ def signer Key.new(pkey) unless pkey.null? end + # Check the signature validity, revalidating it if it was not + # validated before. + # + # @param revalidate [Boolean] force revalidation even if the signature + # was already validated (key signatures only) + # @return [Boolean] true if the signature is valid + def valid?(revalidate: false) + flags = revalidate ? LibRnp::RNP_SIGNATURE_REVALIDATE : 0 + LibRnp.rnp_signature_is_valid(@ptr, flags) == LibRnp::RNP_SUCCESS + end + + # Export the signature. + # + # @param output [Output] the output to write the signature to. + # If nil, the result will be returned directly as a String. + # @param armored [Boolean] whether to ASCII-armor the output + # @return [nil, String] + def export(armored: true, output: nil) + Output.default(output) do |output_| + flags = armored ? LibRnp::RNP_KEY_EXPORT_ARMORED : 0 + Rnp.call_ffi(:rnp_signature_export, @ptr, output_.ptr, flags) + end + end + + # Enumerate each {Subpacket} of this signature. + # + # @return [self, Enumerator] + def each_subpacket(&block) + block or return enum_for(:subpacket_iterator) + subpacket_iterator(&block) + self + end + + # Get a list of all {Subpacket}s of this signature. + # + # @return [Array] + def subpackets + each_subpacket.to_a + end + + # Find the subpacket of the given type. + # + # @param type [Integer] the subpacket type, per the OpenPGP + # specification + # @param hashed [Boolean] if true then only the hashed area is + # searched, otherwise both the hashed and unhashed areas + # @param skip [Integer] number of matching subpackets to skip, + # allowing to iterate over subpackets of the same type + # @return [Subpacket, nil] nil if no matching subpacket was found + def subpacket(type, hashed: false, skip: 0) + pptr = FFI::MemoryPointer.new(:pointer) + rc = LibRnp.rnp_signature_subpacket_find(@ptr, type, hashed, skip, pptr) + return nil if rc == LibRnp::RNP_ERROR_NOT_FOUND + Rnp.raise_error('rnp_signature_subpacket_find failed', rc) unless rc.zero? + psubpkt = pptr.read_pointer + Subpacket.new(psubpkt) unless psubpkt.null? + end + + # Class representing a signature subpacket. + # + # This is a value object: the data is copied out of the signature and + # the underlying handle is released on creation. + class Subpacket + # The subpacket type, per the OpenPGP specification. + # @return [Integer] + attr_reader :type + # The raw subpacket data. + # @return [String] + attr_reader :data + + # @api private + def initialize(ptr) + raise Rnp::Error, 'NULL pointer' if ptr.null? + ptype = FFI::MemoryPointer.new(:uint8) + phashed = FFI::MemoryPointer.new(:bool) + pcritical = FFI::MemoryPointer.new(:bool) + Rnp.call_ffi(:rnp_signature_subpacket_info, ptr, ptype, phashed, + pcritical) + @type = ptype.read(:uint8) + @hashed = phashed.read(:bool) + @critical = pcritical.read(:bool) + pdata = FFI::MemoryPointer.new(:pointer) + psize = FFI::MemoryPointer.new(:size_t) + Rnp.call_ffi(:rnp_signature_subpacket_data, ptr, pdata, psize) + begin + pvalue = pdata.read_pointer + @data = pvalue.read_bytes(psize.read(:size_t)) unless pvalue.null? + ensure + LibRnp.rnp_buffer_destroy(pvalue) + end + ensure + LibRnp.rnp_signature_subpacket_destroy(ptr) if ptr && !ptr.null? + end + + # Whether the subpacket is stored in the hashed area. + # + # @return [Boolean] + def hashed? + @hashed + end + + # Whether the subpacket has the critical bit set. + # + # @return [Boolean] + def critical? + @critical + end + end + # JSON representation of this signature (as a Hash). # # @param mpi [Boolean] if true then MPIs will be included @@ -119,5 +367,37 @@ def string_property(func) LibRnp.rnp_buffer_destroy(pvalue) end end + + def uint32_property(func) + pvalue = FFI::MemoryPointer.new(:uint32) + Rnp.call_ffi(func, @ptr, pvalue) + pvalue.read(:uint32) + end + + def preferred_algs(count_func, at_func) + pcount = FFI::MemoryPointer.new(:size_t) + Rnp.call_ffi(count_func, @ptr, pcount) + pptr = FFI::MemoryPointer.new(:pointer) + (0...pcount.read(:size_t)).map do |idx| + Rnp.call_ffi(at_func, @ptr, idx, pptr) + begin + palg = pptr.read_pointer + palg.read_string unless palg.null? + ensure + LibRnp.rnp_buffer_destroy(palg) + end + end + end + + def subpacket_iterator + pcount = FFI::MemoryPointer.new(:size_t) + Rnp.call_ffi(:rnp_signature_subpacket_count, @ptr, pcount) + pptr = FFI::MemoryPointer.new(:pointer) + (0...pcount.read(:size_t)).each do |idx| + Rnp.call_ffi(:rnp_signature_subpacket_at, @ptr, idx, pptr) + psubpkt = pptr.read_pointer + yield Subpacket.new(psubpkt) unless psubpkt.null? + end + end end end diff --git a/spec/signature_spec.rb b/spec/signature_spec.rb index 62a46c6..841dcec 100644 --- a/spec/signature_spec.rb +++ b/spec/signature_spec.rb @@ -78,3 +78,209 @@ def verify_errors(data, signature) end end end + +describe Rnp::Signature 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(:key) { rnp.find_key(userid: 'key0-uid1') } + + context 'key self-signature' do + let(:sig) { key.uids[0].signatures[0] } + + describe Rnp::Signature.instance_method(:signature_type), + skip: !LibRnp::HAVE_RNP_SIGNATURE_GET_TYPE do + it 'returns the signature type' do + expect(sig.signature_type).to eql 'certification (positive)' + end + end + + describe Rnp::Signature.instance_method(:features), + skip: !LibRnp::HAVE_RNP_SIGNATURE_GET_FEATURES do + it 'returns the key features' do + expect(sig.features).to eql LibRnp::RNP_KEY_FEATURE_MDC + end + end + + describe Rnp::Signature.instance_method(:key_flags), + skip: !LibRnp::HAVE_RNP_SIGNATURE_GET_KEY_FLAGS do + it 'returns the key usage flags' do + expect(sig.key_flags).to eql(LibRnp::RNP_KEY_USAGE_CERTIFY | + LibRnp::RNP_KEY_USAGE_SIGN) + end + end + + describe Rnp::Signature.instance_method(:key_expiration), + skip: !LibRnp::HAVE_RNP_SIGNATURE_GET_KEY_EXPIRATION do + it 'returns the key expiration' do + expect(sig.key_expiration).to be_kind_of(Integer) + end + end + + describe Rnp::Signature.instance_method(:primary_uid?), + skip: !LibRnp::HAVE_RNP_SIGNATURE_GET_PRIMARY_UID do + it 'returns whether the userid is primary' do + expect(sig.primary_uid?).to be false + end + end + + describe Rnp::Signature.instance_method(:key_server), + skip: !LibRnp::HAVE_RNP_SIGNATURE_GET_KEY_SERVER do + it 'returns an empty string when not present' do + expect(sig.key_server).to eql '' + end + end + + describe Rnp::Signature.instance_method(:key_server_prefs), + skip: !LibRnp::HAVE_RNP_SIGNATURE_GET_KEY_SERVER_PREFS do + it 'returns the key server preferences' do + expect(sig.key_server_prefs).to eql LibRnp::RNP_KEY_SERVER_NO_MODIFY + end + end + + describe Rnp::Signature.instance_method(:revoker), + skip: !LibRnp::HAVE_RNP_SIGNATURE_GET_REVOKER do + it 'returns an empty string when not present' do + expect(sig.revoker).to eql '' + end + end + + describe Rnp::Signature.instance_method(:revocation_reason), + skip: !LibRnp::HAVE_RNP_SIGNATURE_GET_REVOCATION_REASON do + it 'returns empty values when not present' do + expect(sig.revocation_reason).to eql({ code: '', reason: '' }) + end + end + + describe Rnp::Signature.instance_method(:trust_level), + skip: !LibRnp::HAVE_RNP_SIGNATURE_GET_TRUST_LEVEL do + it 'returns the trust level and amount' do + expect(sig.trust_level).to eql({ level: 0, amount: 0 }) + end + end + + describe Rnp::Signature.instance_method(:preferred_ciphers), + skip: !LibRnp::HAVE_RNP_SIGNATURE_GET_PREFERRED_ALG do + it 'returns the preferred symmetric algorithms' do + expect(sig.preferred_ciphers) + .to eql %w[AES256 AES192 AES128 CAST5 TRIPLEDES IDEA] + end + end + + describe Rnp::Signature.instance_method(:preferred_hashes), + skip: !LibRnp::HAVE_RNP_SIGNATURE_GET_PREFERRED_HASH do + it 'returns the preferred hash algorithms' do + expect(sig.preferred_hashes) + .to eql %w[SHA256 SHA1 SHA384 SHA512 SHA224] + end + end + + describe Rnp::Signature.instance_method(:preferred_compressions), + skip: !LibRnp::HAVE_RNP_SIGNATURE_GET_PREFERRED_ZALG do + it 'returns the preferred compression algorithms' do + expect(sig.preferred_compressions).to eql %w[ZLIB BZip2 ZIP] + end + end + + describe Rnp::Signature.instance_method(:valid?), + skip: !LibRnp::HAVE_RNP_SIGNATURE_IS_VALID do + it 'returns true for a valid signature' do + expect(sig.valid?).to be true + end + + it 'revalidates the signature' do + expect(sig.valid?(revalidate: true)).to be true + end + end + + describe Rnp::Signature.instance_method(:expiration_time), + skip: !LibRnp::HAVE_RNP_SIGNATURE_GET_EXPIRATION do + it 'returns the signature expiration time' do + expect(sig.expiration_time).to be_kind_of(Integer) + end + end + + describe 'subpackets', skip: !LibRnp::HAVE_RNP_SIGNATURE_SUBPACKET_COUNT do + it 'enumerates the subpackets' do + subpackets = sig.subpackets + expect(subpackets.size).to be 8 + subpackets.each do |subpkt| + expect(subpkt.type).to be_kind_of(Integer) + expect(subpkt.data).to be_kind_of(String) + expect([true, false]).to include subpkt.hashed? + expect([true, false]).to include subpkt.critical? + end + end + + it 'finds a subpacket by type' do + # signature creation time subpacket + subpkt = sig.subpacket(2) + expect(subpkt.type).to be 2 + expect(subpkt.hashed?).to be true + expect(subpkt.data.unpack1('N')).to eql sig.creation_time.to_i + end + + it 'returns nil when no subpacket matches' do + expect(sig.subpacket(250)).to be_nil + end + + it 'enumerates subpackets with each_subpacket' do + expect(sig.each_subpacket.to_a.size).to be 8 + end + end + + describe Rnp::Signature.instance_method(:export), + skip: !LibRnp::HAVE_RNP_SIGNATURE_EXPORT do + it 'exports an armored signature' do + exported = sig.export + expect(exported.start_with?("-----BEGIN PGP PUBLIC KEY BLOCK-----\r\n")) + .to be true + expect(exported.end_with?("-----END PGP PUBLIC KEY BLOCK-----\r\n")) + .to be true + end + + it 'exports a binary signature' do + exported = sig.export(armored: false) + expect(exported.encoding).to eql Encoding::BINARY + # a signature packet (old format, tag 2) + expect(exported.getbyte(0) & 0x3F).to eql 2 + end + end + end + + context 'data signature' do + let(:sig) do + signature = rnp.detached_sign(input: Rnp::Input.from_string('data'), + signers: [key], + hash: 'SHA256', + armored: false) + verify = rnp.start_detached_verify(data: Rnp::Input.from_string('data'), + signature: Rnp::Input.from_string(signature)) + verify.execute + verify.signatures[0].handle + end + + it 'has the expected type and signer' do + expect(sig.signature_type).to eql 'binary' + expect(sig.type).to eql 'RSA' + expect(sig.hash).to eql 'SHA256' + expect(sig.keyid).to eql key.keyid + expect(sig.fingerprint).to eql key.fingerprint + end + + it 'is valid' do + expect(sig.valid?).to be true + end + + it 'enumerates the subpackets', + skip: !LibRnp::HAVE_RNP_SIGNATURE_SUBPACKET_COUNT do + expect(sig.subpackets.size).to be > 0 + # issuer fingerprint subpacket + expect(sig.subpacket(33)).to_not be_nil + end + end +end diff --git a/spec/verify/introspection_spec.rb b/spec/verify/introspection_spec.rb new file mode 100644 index 0000000..27a3ccf --- /dev/null +++ b/spec/verify/introspection_spec.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +# (c) 2026 Ribose Inc. + +require 'spec_helper' + +describe Rnp::Verify 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(:key) { rnp.find_key(userid: 'key0-uid1') } + + describe Rnp::Verify.instance_method(:format), + skip: !LibRnp::HAVE_RNP_OP_VERIFY_GET_FORMAT do + it 'returns the literal data format for a signed message' do + signature = rnp.sign(input: Rnp::Input.from_string('data'), + signers: [key], + armored: false) + verify = rnp.start_verify(input: Rnp::Input.from_string(signature), + output: Rnp::Output.to_null) + verify.execute + expect(verify.format).to eql 'b' + end + + it 'returns the literal data format for an encrypted message' do + encrypted = rnp.encrypt(input: Rnp::Input.from_string('data'), + recipients: [key], + armored: false) + verify = rnp.start_verify(input: Rnp::Input.from_string(encrypted), + output: Rnp::Output.to_null) + verify.execute + expect(verify.format).to eql 'b' + end + end + + describe 'recipient enumeration', + skip: !LibRnp::HAVE_RNP_OP_VERIFY_GET_RECIPIENT_COUNT do + let(:encrypted) do + rnp.encrypt(input: Rnp::Input.from_string('data'), + recipients: [key], + armored: false) + end + + it 'enumerates the recipients' do + verify = rnp.start_verify(input: Rnp::Input.from_string(encrypted), + output: Rnp::Output.to_null) + verify.execute + recipients = verify.recipients + expect(recipients.size).to be 1 + expect(recipients[0].keyid).to eql '8A05B89FAD5ADED1' + expect(recipients[0].alg).to eql 'RSA' + end + + it 'returns the recipient used to decrypt' do + verify = rnp.start_verify(input: Rnp::Input.from_string(encrypted), + output: Rnp::Output.to_null) + verify.execute + expect(verify.used_recipient.keyid).to eql '8A05B89FAD5ADED1' + end + + it 'returns no recipients for a non-encrypted message' do + signature = rnp.sign(input: Rnp::Input.from_string('data'), + signers: [key], + armored: false) + verify = rnp.start_verify(input: Rnp::Input.from_string(signature), + output: Rnp::Output.to_null) + verify.execute + expect(verify.recipients).to eql [] + expect(verify.used_recipient).to be_nil + end + end + + describe 'symenc enumeration', + skip: !LibRnp::HAVE_RNP_OP_VERIFY_GET_SYMENC_COUNT do + let(:encrypted) do + rnp.symmetric_encrypt(input: Rnp::Input.from_string('data'), + passwords: %w[pw0 pw1], + armored: false) + end + + it 'enumerates the password-based entries' do + verify = rnp.start_verify(input: Rnp::Input.from_string(encrypted), + output: Rnp::Output.to_null) + begin + verify.execute + rescue Rnp::Error + # the password provider does not return a matching password + end + symencs = verify.symencs + expect(symencs.size).to be 2 + symencs.each do |symenc| + expect(symenc.cipher).to eql 'AES256' + expect(symenc.aead_alg).to eql 'None' + expect(symenc.hash_alg).to eql 'SHA256' + expect(symenc.s2k_type).to eql 'Iterated and salted' + expect(symenc.s2k_iterations).to be > 0 + end + expect(verify.used_symenc).to be_nil + end + + context 'when a password matches' do + let(:rnp) do + rnp = Rnp.new + rnp.password_provider = 'pw1' + rnp + end + + it 'returns the symenc entry used to decrypt' do + verify = rnp.start_verify(input: Rnp::Input.from_string(encrypted), + output: Rnp::Output.to_null) + verify.execute + expect(verify.symencs.size).to be 2 + expect(verify.used_symenc.cipher).to eql 'AES256' + end + end + end + + describe Rnp::Verify.instance_method(:flags=), + skip: !LibRnp::HAVE_RNP_OP_VERIFY_SET_FLAGS do + let(:encrypted_and_signed) do + # encrypt with a password (so decryption works without any keys) + # and sign with a key the verifying instance does not have + output = Rnp::Output.to_string + encrypt = rnp.start_encrypt(input: Rnp::Input.from_string('data'), + output: output) + encrypt.add_password('pw') + encrypt.add_signer(key) + encrypt.execute + output.string + end + + it 'fails verification with an unknown signer key by default' do + other = Rnp.new + other.password_provider = 'pw' + expect do + other.verify(input: Rnp::Input.from_string(encrypted_and_signed), + output: Rnp::Output.to_null) + end.to raise_error(Rnp::Error) + end + + it 'decrypts despite an unknown signer with IGNORE_SIGS_ON_DECRYPT' do + other = Rnp.new + other.password_provider = 'pw' + output = Rnp::Output.to_string + verify = other.start_verify(input: Rnp::Input.from_string(encrypted_and_signed), + output: output) + verify.flags = LibRnp::RNP_VERIFY_IGNORE_SIGS_ON_DECRYPT + expect { verify.execute }.to_not raise_error + expect(output.string).to eql 'data' + end + end +end From d5d8381a369749bdbf782b09f2a8e0657e546939 Mon Sep 17 00:00:00 2001 From: Ronald Tse Date: Mon, 20 Jul 2026 01:35:22 +0800 Subject: [PATCH 03/14] Add key certification API (Rnp::KeySignature) KeySignature wraps the editable key signature handle created by Key#start_certification, Key#start_direct_signature and Key#start_revocation_signature. It supports setting the hash, creation time, key flags/expiration, features, preferred algorithms, primary userid flag, key server (+preferences), revocation reason, designated revoker and trust level, and is finalized with #sign. Also Key#revocation_signature, Key#remove_signature (single) and Key#remove_signatures (filtered by invalid/unknown-key/non-self flags). --- lib/rnp.rb | 1 + lib/rnp/key.rb | 97 +++++++++++++++++++ lib/rnp/key_signature.rb | 191 +++++++++++++++++++++++++++++++++++++ spec/key_signature_spec.rb | 159 ++++++++++++++++++++++++++++++ 4 files changed, 448 insertions(+) create mode 100644 lib/rnp/key_signature.rb create mode 100644 spec/key_signature_spec.rb diff --git a/lib/rnp.rb b/lib/rnp.rb index 3d5f297..f368007 100644 --- a/lib/rnp.rb +++ b/lib/rnp.rb @@ -10,6 +10,7 @@ require 'rnp/op/verify' require 'rnp/output' require 'rnp/rnp' +require 'rnp/key_signature' require 'rnp/signature' require 'rnp/userid' require 'rnp/version' diff --git a/lib/rnp/key.rb b/lib/rnp/key.rb index 375b87f..dc368f1 100644 --- a/lib/rnp/key.rb +++ b/lib/rnp/key.rb @@ -433,6 +433,103 @@ def expiration_time Time.at(ptime.read(:uint32)) end + # Get the key's revocation signature, if any. + # + # @return [Signature, nil] nil if there is no valid revocation + # signature + def revocation_signature + pptr = FFI::MemoryPointer.new(:pointer) + Rnp.call_ffi(:rnp_key_get_revocation_signature, @ptr, pptr) + psig = pptr.read_pointer + Signature.new(psig) unless psig.null? + end + + # Start building a certification signature over a userid, issued by + # this key. Customize the returned {KeySignature} and call + # {KeySignature#sign} to finalize it. + # + # @note This key (the signer) must be secret. + # + # @param uid [UserID] the userid to certify. It may belong to this key + # (a self-certification) or to another key. + # @param type [String, nil] the certification type ('generic', + # 'persona', 'casual' or 'positive'). If nil, the default is used + # ('positive' for a self-certification, 'generic' otherwise). + # @return [KeySignature] + def start_certification(uid, type: nil) + pptr = FFI::MemoryPointer.new(:pointer) + Rnp.call_ffi(:rnp_key_certification_create, @ptr, uid.ptr, type, pptr) + psig = pptr.read_pointer + KeySignature.new(psig) unless psig.null? + end + + # Start building a direct-key signature, issued by this key. Customize + # the returned {KeySignature} and call {KeySignature#sign} to finalize + # it. + # + # @note This key (the signer) must be secret. + # + # @param target [Key, nil] the key to sign. If nil, the signature is + # made over this key itself (a self-signature). + # @return [KeySignature] + def start_direct_signature(target = nil) + pptr = FFI::MemoryPointer.new(:pointer) + Rnp.call_ffi(:rnp_key_direct_signature_create, @ptr, target&.ptr, + pptr) + psig = pptr.read_pointer + KeySignature.new(psig) unless psig.null? + end + + # Start building a key or subkey revocation signature, issued by this + # key. Customize the returned {KeySignature} and call + # {KeySignature#sign} to finalize it. + # + # @note This key (the revoker) must be secret. + # + # @param target [Key, nil] the key to revoke. If nil, this key will + # revoke itself. + # @return [KeySignature] + def start_revocation_signature(target = nil) + pptr = FFI::MemoryPointer.new(:pointer) + Rnp.call_ffi(:rnp_key_revocation_signature_create, @ptr, target&.ptr, + pptr) + psig = pptr.read_pointer + KeySignature.new(psig) unless psig.null? + end + + # Remove unneeded signatures from the key, its userids and subkeys. + # + # @note Any signature handles related to this key, its uids or subkeys + # should not be used after this call. + # + # @param invalid [Boolean] remove signatures that are invalid and were + # never valid + # @param unknown_key [Boolean] remove signatures made by an unknown + # key + # @param non_self [Boolean] remove signatures that are not + # self-signatures + # @return [void] + def remove_signatures(invalid: false, unknown_key: false, + non_self: false) + flags = 0 + flags |= LibRnp::RNP_KEY_SIGNATURE_INVALID if invalid + flags |= LibRnp::RNP_KEY_SIGNATURE_UNKNOWN_KEY if unknown_key + flags |= LibRnp::RNP_KEY_SIGNATURE_NON_SELF_SIG if non_self + Rnp.call_ffi(:rnp_key_remove_signatures, @ptr, flags, nil, nil) + end + + # Remove a single signature from the key. + # + # @note The signature must have been obtained via this key or one of + # its userids. Other handles of the same signature should not be + # used after this call. + # + # @param signature [Signature] the signature to remove + # @return [void] + def remove_signature(signature) + Rnp.call_ffi(:rnp_signature_remove, @ptr, signature.ptr) + end + private def string_property(func) diff --git a/lib/rnp/key_signature.rb b/lib/rnp/key_signature.rb new file mode 100644 index 0000000..228d835 --- /dev/null +++ b/lib/rnp/key_signature.rb @@ -0,0 +1,191 @@ +# frozen_string_literal: true + +# (c) 2026 Ribose Inc. + +require 'ffi' + +require 'rnp/error' +require 'rnp/ffi/librnp' +require 'rnp/utils' + +class Rnp + # Class that represents an editable key signature (a certification, + # direct-key or revocation signature) being built. + # + # It is created via one of {Key#start_certification}, + # {Key#start_direct_signature} or {Key#start_revocation_signature}, + # customized via the setter methods, and finalized (which also adds it + # to the corresponding key) via {#sign}. + # + # @note The signing (certifying) key must be secret and must remain in + # the keyring until {#sign} is called. + class KeySignature + # @api private + attr_reader :ptr + + # @api private + def initialize(ptr) + raise Rnp::Error, 'NULL pointer' if ptr.null? + @ptr = FFI::AutoPointer.new(ptr, self.class.method(:destroy)) + end + + # @api private + def self.destroy(ptr) + LibRnp.rnp_signature_handle_destroy(ptr) + end + + def inspect + Rnp.inspect_ptr(self) + end + + # Set the hash algorithm used during signing. + # + # @param hash [String] the hash algorithm name ('SHA256', etc) + # @return [void] + def hash=(hash) + Rnp.call_ffi(:rnp_key_signature_set_hash, @ptr, hash.to_s) + end + + # Set the signature creation time (the current time is used by + # default). + # + # @param creation_time [Time, Integer] the creation time. As an + # integer, this is the number of seconds since the unix epoch. + # @return [void] + def creation_time=(creation_time) + creation_time = creation_time.to_i if creation_time.is_a?(::Time) + Rnp.call_ffi(:rnp_key_signature_set_creation, @ptr, creation_time) + end + + # Set the key usage flags, i.e. whether the key is usable for signing, + # encryption, etc. + # + # @note RNP does not check whether the flags are applicable to the key + # itself (i.e. the signing flag for an encryption-only key). + # + # @param flags [Integer] OR-ed combination of the + # LibRnp::RNP_KEY_USAGE_* constants + # @return [void] + def key_flags=(flags) + Rnp.call_ffi(:rnp_key_signature_set_key_flags, @ptr, flags) + end + + # Set the key expiration time. Makes sense only for self-certification + # or direct-key signatures. + # + # @param expiration [Integer] the number of seconds since the key + # creation time when the key is considered valid. A value of 0 + # indicates no expiration. + # @return [void] + def key_expiration=(expiration) + Rnp.call_ffi(:rnp_key_signature_set_key_expiration, @ptr, expiration) + end + + # Set the key features. Makes sense only for self-signatures. + # + # @param features [Integer] OR-ed combination of the + # LibRnp::RNP_KEY_FEATURE_* constants + # @return [void] + def features=(features) + Rnp.call_ffi(:rnp_key_signature_set_features, @ptr, features) + end + + # Add a preferred symmetric algorithm. Should be called for each + # algorithm, with the first ones having higher priority. + # + # @param cipher [String] the cipher algorithm name + # @return [void] + def add_preferred_cipher(cipher) + Rnp.call_ffi(:rnp_key_signature_add_preferred_alg, @ptr, cipher.to_s) + end + + # Add a preferred hash algorithm. Should be called for each algorithm, + # with the first ones having higher priority. + # + # @param hash [String] the hash algorithm name + # @return [void] + def add_preferred_hash(hash) + Rnp.call_ffi(:rnp_key_signature_add_preferred_hash, @ptr, hash.to_s) + end + + # Add a preferred compression algorithm. Should be called for each + # algorithm, with the first ones having higher priority. + # + # @param zalg [String] the compression algorithm name + # @return [void] + def add_preferred_compression(zalg) + Rnp.call_ffi(:rnp_key_signature_add_preferred_zalg, @ptr, zalg.to_s) + end + + # Set whether the certified userid should be considered as the primary + # one. Makes sense only for self-certifications. + # + # @param primary [Boolean] + # @return [void] + def primary_uid=(primary) + Rnp.call_ffi(:rnp_key_signature_set_primary_uid, @ptr, primary) + end + + # Set the key server URL applicable for the key. + # + # @param key_server [String, nil] the key server URL. If nil or empty, + # the key server field is removed from the signature. + # @return [void] + def key_server=(key_server) + Rnp.call_ffi(:rnp_key_signature_set_key_server, @ptr, key_server) + end + + # Set the key server preferences flags. + # + # @param flags [Integer] OR-ed combination of the + # LibRnp::RNP_KEY_SERVER_* constants + # @return [void] + def key_server_prefs=(flags) + Rnp.call_ffi(:rnp_key_signature_set_key_server_prefs, @ptr, flags) + end + + # Set the revocation reason code and text. Makes sense only for + # revocation signatures. + # + # @param code [String, nil] the revocation reason code ('no reason', + # 'superseded', 'compromised', 'retired'). If nil, the default one + # is used. + # @param reason [String, nil] the human-readable reason + # @return [void] + def set_revocation_reason(code, reason) + Rnp.call_ffi(:rnp_key_signature_set_revocation_reason, @ptr, code, + reason) + end + + # Set the designated revoker (a key which is allowed to revoke this + # key). Only a single revoker can be set: subsequent calls overwrite + # the previous one. + # + # @param key [Key] the revoker's key + # @param sensitive [Boolean] whether information about the revocation + # key should be considered sensitive + # @return [void] + def set_revoker(key, sensitive: false) + flags = sensitive ? LibRnp::RNP_REVOKER_SENSITIVE : 0 + Rnp.call_ffi(:rnp_key_signature_set_revoker, @ptr, key.ptr, flags) + end + + # Set the signature trust level and amount. Makes sense only for + # certifications of other keys. + # + # @param level [Integer] the trust level + # @param amount [Integer] the trust amount + # @return [void] + def set_trust_level(level, amount) + Rnp.call_ffi(:rnp_key_signature_set_trust_level, @ptr, level, amount) + end + + # Finalize and sign the signature, adding it to the corresponding key. + # + # @return [self] + def sign + Rnp.call_ffi(:rnp_key_signature_sign, @ptr) + self + end + end +end diff --git a/spec/key_signature_spec.rb b/spec/key_signature_spec.rb new file mode 100644 index 0000000..d6953c0 --- /dev/null +++ b/spec/key_signature_spec.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +# (c) 2026 Ribose Inc. + +require 'spec_helper' + +describe Rnp::KeySignature, + skip: !LibRnp::HAVE_RNP_KEY_CERTIFICATION_CREATE do + def generate(rnp, userid) + op = rnp.start_generate(type: 'RSA') + op.bits = 1024 + op.userid = userid + op.execute + op.key + end + + let(:rnp) do + rnp = Rnp.new + rnp.password_provider = 'password' + rnp + end + + describe 'certification' do + let(:signer) { generate(rnp, 'certifier') } + let(:target) { generate(rnp, 'certified') } + + it 'certifies another key, applying all customizations' do + # the signature must not be older than either key + creation = [signer.creation_time.to_i, target.creation_time.to_i].max + keysig = signer.start_certification(target.uids[0]) + keysig.hash = 'SHA512' + keysig.creation_time = creation + keysig.set_trust_level(2, 120) + keysig.key_server = 'hkps://keys.example.com' + keysig.key_server_prefs = LibRnp::RNP_KEY_SERVER_NO_MODIFY + keysig.add_preferred_cipher('AES256') + keysig.add_preferred_hash('SHA512') + keysig.add_preferred_compression('ZLIB') + keysig.sign + + sig = target.uids[0].signatures.find { |s| s.keyid == signer.keyid } + expect(sig).to_not be_nil + expect(sig.signature_type).to eql 'certification (generic)' + expect(sig.hash).to eql 'SHA512' + expect(sig.creation_time.to_i).to eql creation + expect(sig.trust_level).to eql({ level: 2, amount: 120 }) + expect(sig.key_server).to eql 'hkps://keys.example.com' + expect(sig.key_server_prefs).to eql LibRnp::RNP_KEY_SERVER_NO_MODIFY + expect(sig.preferred_ciphers).to eql %w[AES256] + expect(sig.preferred_hashes).to eql %w[SHA512] + expect(sig.preferred_compressions).to eql %w[ZLIB] + expect(sig.fingerprint).to eql signer.fingerprint + expect(sig.valid?).to be true + end + + it 'certifies with an explicit type' do + keysig = signer.start_certification(target.uids[0], type: 'persona') + keysig.sign + sig = target.uids[0].signatures.find { |s| s.keyid == signer.keyid } + expect(sig.signature_type).to eql 'certification (persona)' + end + + it 'self-certifies, marking the userid as primary' do + keysig = target.start_certification(target.uids[0]) + keysig.primary_uid = true + keysig.sign + sig = target.uids[0].signatures.find do |s| + s.keyid == target.keyid && s.primary_uid? + end + expect(sig).to_not be_nil + expect(sig.signature_type).to eql 'certification (positive)' + end + end + + describe 'direct-key signature' do + let(:key) { generate(rnp, 'direct') } + + it 'creates a direct-key self-signature, applying customizations' do + keysig = key.start_direct_signature + keysig.features = LibRnp::RNP_KEY_FEATURE_MDC + keysig.key_flags = LibRnp::RNP_KEY_USAGE_CERTIFY | + LibRnp::RNP_KEY_USAGE_SIGN + keysig.key_expiration = 86_400 + keysig.sign + + sig = key.signatures.find { |s| s.signature_type == 'direct' } + expect(sig).to_not be_nil + expect(sig.features).to eql LibRnp::RNP_KEY_FEATURE_MDC + expect(sig.key_flags).to eql(LibRnp::RNP_KEY_USAGE_CERTIFY | + LibRnp::RNP_KEY_USAGE_SIGN) + expect(sig.key_expiration).to eql 86_400 + expect(sig.valid?).to be true + end + + it 'creates a direct-key signature over another key' do + other = generate(rnp, 'other') + keysig = key.start_direct_signature(other) + keysig.sign + sig = other.signatures.find { |s| s.keyid == key.keyid } + expect(sig).to_not be_nil + expect(sig.signature_type).to eql 'direct' + end + end + + describe 'revocation signature' do + let(:key) { generate(rnp, 'revokee') } + + it 'revokes the key with a reason' do + keysig = key.start_revocation_signature + keysig.set_revocation_reason('retired', 'no longer used') + keysig.sign + + expect(key.revoked?).to be true + expect(key.retired?).to be true + expect(key.revocation_reason).to eql 'no longer used' + + sig = key.revocation_signature + expect(sig).to_not be_nil + expect(sig.signature_type).to eql 'key revocation' + expect(sig.revocation_reason).to eql({ code: 'retired', + reason: 'no longer used' }) + expect(sig.valid?).to be true + end + end + + describe 'signature removal' do + let(:signer) { generate(rnp, 'remover') } + let(:target) { generate(rnp, 'removee') } + + before do + signer.start_certification(target.uids[0]).sign + end + + describe Rnp::Key.instance_method(:remove_signature), + skip: !LibRnp::HAVE_RNP_SIGNATURE_REMOVE do + it 'removes a single signature' do + sig = target.uids[0].signatures.find { |s| s.keyid == signer.keyid } + expect(sig).to_not be_nil + target.remove_signature(sig) + keyids = target.uids[0].signatures.map(&:keyid) + expect(keyids).to_not include signer.keyid + end + end + + describe Rnp::Key.instance_method(:remove_signatures), + skip: !LibRnp::HAVE_RNP_KEY_REMOVE_SIGNATURES do + it 'removes non-self signatures' do + expect(target.uids[0].signatures.map(&:keyid)) + .to include signer.keyid + target.remove_signatures(non_self: true) + expect(target.uids[0].signatures.map(&:keyid)) + .to_not include signer.keyid + # the self-signature is kept + expect(target.uids[0].signatures.map(&:keyid)) + .to include target.keyid + end + end + end +end From b816ef16be6a7f3f49d08e1f24f7e3d6e27a7314 Mon Sep 17 00:00:00 2001 From: Ronald Tse Date: Mon, 20 Jul 2026 01:43:46 +0800 Subject: [PATCH 04/14] Add security profile API Rnp#add_security_rule, #security_rule and #remove_security_rule wrap the librnp security rule API (0.16.3+), allowing to override the default algorithm security levels (e.g. to accept SHA1 signatures). Rnp#timestamp= wraps rnp_set_timestamp. --- lib/rnp.rb | 1 + lib/rnp/security.rb | 147 ++++++++++++++++++++++++++++++++++++++++++ spec/security_spec.rb | 116 +++++++++++++++++++++++++++++++++ 3 files changed, 264 insertions(+) create mode 100644 lib/rnp/security.rb create mode 100644 spec/security_spec.rb diff --git a/lib/rnp.rb b/lib/rnp.rb index f368007..4668c4e 100644 --- a/lib/rnp.rb +++ b/lib/rnp.rb @@ -11,6 +11,7 @@ require 'rnp/output' require 'rnp/rnp' require 'rnp/key_signature' +require 'rnp/security' require 'rnp/signature' require 'rnp/userid' require 'rnp/version' diff --git a/lib/rnp/security.rb b/lib/rnp/security.rb new file mode 100644 index 0000000..a7e7f1f --- /dev/null +++ b/lib/rnp/security.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +# (c) 2026 Ribose Inc. + +require 'ffi' + +require 'rnp/error' +require 'rnp/ffi/librnp' +require 'rnp/utils' + +class Rnp + # Security rule levels, as accepted by {#add_security_rule}. + SECURITY_RULE_LEVELS = { + prohibited: LibRnp::RNP_SECURITY_PROHIBITED, + insecure: LibRnp::RNP_SECURITY_INSECURE, + default: LibRnp::RNP_SECURITY_DEFAULT + }.freeze + + # Add a security rule, overriding the default algorithm security + # settings. + # + # @note Key signature validation status is cached, so rules should be + # changed before keyrings are loaded (or the keyring should be + # reloaded after updating rules). + # + # @param type [String] the feature type. Currently only + # 'hash algorithm' is supported. + # @param name [String] the feature name ('SHA1', 'MD5', etc). The same + # values are used as in {Rnp.supports?}. + # @param level [Symbol, Integer] the security level: :prohibited (the + # feature is completely disabled), :insecure (valid signatures + # produced after `from` are marked as invalid) or :default (the + # feature is secure enough). The LibRnp::RNP_SECURITY_* level + # constants are also accepted. + # @param from [Time, Integer] the timestamp from which the rule is + # active. Objects that have a creation time (like signatures) are + # matched with the closest rule from the past, unless there is a rule + # with the override flag. + # @param override [Boolean] whether the rule overrides all other rules + # for the specified feature. May be used to temporarily enable or + # disable some feature value, reverting the change later via + # {#remove_security_rule}. + # @param usage [Symbol, nil] :key to limit the rule to key signature + # verification, :data to limit it to data signature verification, or + # nil to apply it to all usages. + # @return [void] + def add_security_rule(type:, name:, level:, from: 0, override: false, + usage: nil) + flags = 0 + flags |= LibRnp::RNP_SECURITY_OVERRIDE if override + flags |= security_rule_usage(usage) + Rnp.call_ffi(:rnp_add_security_rule, @ptr, type, name, flags, + security_rule_time(from), security_rule_level(level)) + end + + # Get the security rule applicable for the given feature value and + # timestamp. + # + # @note If there is no matching rule, this falls back to the default + # security level with empty flags and `from`. + # + # @param type (see #add_security_rule) + # @param name (see #add_security_rule) + # @param time [Time, Integer] the timestamp for which the feature + # should be checked + # @param usage (see #add_security_rule) + # @return [Hash] a hash with :flags, :from and :level + # keys. :level is one of the LibRnp::RNP_SECURITY_* level constants + # (RNP_SECURITY_PROHIBITED, RNP_SECURITY_INSECURE, + # RNP_SECURITY_DEFAULT). + def security_rule(type:, name:, time: 0, usage: nil) + pflags = FFI::MemoryPointer.new(:uint32) + pflags.write(:uint32, security_rule_usage(usage)) + pfrom = FFI::MemoryPointer.new(:uint64) + plevel = FFI::MemoryPointer.new(:uint32) + Rnp.call_ffi(:rnp_get_security_rule, @ptr, type, name, + security_rule_time(time), pflags, pfrom, plevel) + { + flags: pflags.read(:uint32), + from: pfrom.read(:uint64), + level: plevel.read(:uint32) + } + end + + # Remove security rule(s) matching the parameters. + # + # @note Use this with caution: this may also clear the default security + # rules, so all affected features would be considered of the default + # security level. + # + # @param type [String, nil] the feature type. If nil, all rules will be + # cleared. + # @param name [String, nil] the feature name. If nil, all rules of the + # type will be cleared. + # @param level (see #add_security_rule) + # @param override [Boolean] match only rules with the override flag + # @param usage (see #add_security_rule) + # @param all [Boolean] remove all rules for the type and name + # @param from [Time, Integer] the timestamp for which the rule should + # be removed. Ignored when `all` is true. + # @return [Integer] the number of removed rules + def remove_security_rule(type:, name:, level: :default, override: false, + usage: nil, all: false, from: 0) + flags = 0 + flags |= LibRnp::RNP_SECURITY_OVERRIDE if override + flags |= security_rule_usage(usage) + flags |= LibRnp::RNP_SECURITY_REMOVE_ALL if all + premoved = FFI::MemoryPointer.new(:size_t) + Rnp.call_ffi(:rnp_remove_security_rule, @ptr, type, name, + security_rule_level(level), flags, + security_rule_time(from), premoved) + premoved.read(:size_t) + end + + # Set the timestamp used in all operations instead of the system time. + # This includes key/signature generation (used as the creation date) + # and verification of keys and signatures (used as the current time). + # + # @param time [Time, Integer] the timestamp to use. A value of 0 + # restores the original behavior (the system time is used). + # @return [void] + def timestamp=(time) + Rnp.call_ffi(:rnp_set_timestamp, @ptr, security_rule_time(time)) + end + + private + + def security_rule_level(level) + return level if level.is_a?(Integer) + SECURITY_RULE_LEVELS.fetch(level) do + raise ArgumentError, "invalid security rule level: #{level.inspect}" + end + end + + def security_rule_usage(usage) + case usage + when nil then 0 + when :key then LibRnp::RNP_SECURITY_VERIFY_KEY + when :data then LibRnp::RNP_SECURITY_VERIFY_DATA + else raise ArgumentError, "invalid security rule usage: #{usage.inspect}" + end + end + + def security_rule_time(time) + time.is_a?(::Time) ? time.to_i : time + end +end diff --git a/spec/security_spec.rb b/spec/security_spec.rb new file mode 100644 index 0000000..646b0b4 --- /dev/null +++ b/spec/security_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +# (c) 2026 Ribose Inc. + +require 'spec_helper' + +describe 'security profile' 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 + + describe Rnp.instance_method(:security_rule), + skip: !LibRnp::HAVE_RNP_GET_SECURITY_RULE do + it 'returns the default rule for a hash algorithm' do + # SHA1 data signatures are marked insecure since 2019-01-19 by + # the default security profile + rule = rnp.security_rule(type: 'hash algorithm', name: 'SHA1', + time: 1_700_000_000, usage: :data) + expect(rule[:level]).to eql LibRnp::RNP_SECURITY_INSECURE + expect(rule[:from]).to eql 1_547_856_000 + end + + it 'falls back to the default level when there is no matching rule' do + rule = rnp.security_rule(type: 'hash algorithm', name: 'SHA256', + time: 1_700_000_000) + expect(rule[:level]).to eql LibRnp::RNP_SECURITY_DEFAULT + expect(rule[:from]).to eql 0 + end + + it 'raises an error on an unknown feature name' do + expect do + rnp.security_rule(type: 'hash algorithm', name: 'FAKE') + end.to raise_error(Rnp::Error) + end + end + + describe 'security rule allow/deny roundtrip', + skip: !LibRnp::HAVE_RNP_ADD_SECURITY_RULE do + let(:sha1_signature) do + rnp.detached_sign(input: Rnp::Input.from_string('data'), + signers: [rnp.find_key(userid: 'key0-uid1')], + hash: 'SHA1', + armored: false) + end + + def verify(signature) + verify = rnp.start_detached_verify( + data: Rnp::Input.from_string('data'), + signature: Rnp::Input.from_string(signature) + ) + verify.execute + end + + it 'rejects SHA1 signatures, allows them with an override rule, and rejects again after removal' do + # the default profile marks SHA1 data signatures as insecure + expect { verify(sha1_signature) } + .to raise_error(Rnp::InvalidSignatureError) + + # add an overriding rule, allowing SHA1 + rnp.add_security_rule(type: 'hash algorithm', name: 'SHA1', + level: :default, override: true) + expect { verify(sha1_signature) }.to_not raise_error + + # remove the overriding rule, restoring the default behavior + removed = rnp.remove_security_rule(type: 'hash algorithm', + name: 'SHA1', + level: :default, + override: true) + expect(removed).to be 1 + expect { verify(sha1_signature) } + .to raise_error(Rnp::InvalidSignatureError) + end + + it 'reports the added rule via security_rule' do + from = Time.at(1_600_000_000) + rnp.add_security_rule(type: 'hash algorithm', name: 'SHA512', + level: :insecure, from: from) + rule = rnp.security_rule(type: 'hash algorithm', name: 'SHA512', + time: 1_700_000_000) + expect(rule[:level]).to eql LibRnp::RNP_SECURITY_INSECURE + expect(rule[:from]).to eql 1_600_000_000 + end + + it 'rejects an invalid level' do + expect do + rnp.add_security_rule(type: 'hash algorithm', name: 'SHA1', + level: :bogus) + end.to raise_error(ArgumentError) + end + + it 'rejects an invalid usage' do + expect do + rnp.add_security_rule(type: 'hash algorithm', name: 'SHA1', + level: :default, usage: :bogus) + end.to raise_error(ArgumentError) + end + end + + describe Rnp.instance_method(:timestamp=), + skip: !LibRnp::HAVE_RNP_SET_TIMESTAMP do + it 'uses the timestamp as the creation time for generated keys' do + stamp = 1_500_000_000 + rnp.timestamp = stamp + op = rnp.start_generate(type: 'RSA') + op.bits = 1024 + op.userid = 'timestamped' + op.execute + expect(op.key.creation_time.to_i).to eql stamp + rnp.timestamp = 0 + end + end +end From da8b6eef6a9007411ad6baa1ed9406f7c0825364 Mon Sep 17 00:00:00 2001 From: Ronald Tse Date: Mon, 20 Jul 2026 01:44:27 +0800 Subject: [PATCH 05/14] Add key inspection getters New Key methods: version, expired?, valid?, valid_till, revokers, primary_fingerprint and the secret key protection information (protection_type, protection_mode, protection_cipher, protection_hash, protection_iterations). --- lib/rnp/key.rb | 118 ++++++++++++++++++++++++++++++ spec/key/inspection_spec.rb | 140 ++++++++++++++++++++++++++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 spec/key/inspection_spec.rb diff --git a/lib/rnp/key.rb b/lib/rnp/key.rb index dc368f1..713ab39 100644 --- a/lib/rnp/key.rb +++ b/lib/rnp/key.rb @@ -433,6 +433,75 @@ def expiration_time Time.at(ptime.read(:uint32)) end + # Get the OpenPGP version of the key. + # + # @return [Integer] + def version + pversion = FFI::MemoryPointer.new(:uint32) + Rnp.call_ffi(:rnp_key_get_version, @ptr, pversion) + pversion.read(:uint32) + end + + # Check whether the key is expired. + # + # @note While an expired key cannot be used to generate new signatures + # or encrypt to, it can still be used to verify older signatures and + # decrypt previously encrypted data. + # + # @return [Boolean] + def expired? + bool_property(:rnp_key_is_expired) + end + + # Check whether the public key is valid. This includes checks of the + # self-signatures, expiration times, revocations, etc. + # + # @return [Boolean] + def valid? + bool_property(:rnp_key_is_valid) + end + + # Get the time till which the key can be considered valid. This takes + # into account not only the key's expiration, but revocations as well + # (and, for a subkey, the primary key's validity time). + # + # @return [Time, nil] nil if the key never expires + def valid_till + ptime = FFI::MemoryPointer.new(:uint64) + Rnp.call_ffi(:rnp_key_valid_till64, @ptr, ptime) + time = ptime.read(:uint64) + time == (1 << 64) - 1 ? nil : Time.at(time) + end + + # Get the fingerprint of the primary key. + # + # @note This is only valid for subkeys and raises an error for a + # primary key. + # + # @return [String] + def primary_fingerprint + string_property(:rnp_key_get_primary_fprint) + end + + # Get a list of the designated revokers of the key (keys which are + # allowed to revoke this key). + # + # @return [Array] a list of revoker key fingerprints + def revokers + pcount = FFI::MemoryPointer.new(:size_t) + Rnp.call_ffi(:rnp_key_get_revoker_count, @ptr, pcount) + pptr = FFI::MemoryPointer.new(:pointer) + (0...pcount.read(:size_t)).map do |idx| + Rnp.call_ffi(:rnp_key_get_revoker_at, @ptr, idx, pptr) + begin + prevoker = pptr.read_pointer + prevoker.read_string unless prevoker.null? + ensure + LibRnp.rnp_buffer_destroy(prevoker) + end + end + end + # Get the key's revocation signature, if any. # # @return [Signature, nil] nil if there is no valid revocation @@ -444,6 +513,55 @@ def revocation_signature Signature.new(psig) unless psig.null? end + # Get the type of protection used for the secret key data. + # + # @return [String] one of 'None', 'Encrypted', 'Encrypted-Hashed', + # 'GPG-None', 'GPG-Smartcard' or 'Unknown' + def protection_type + string_property(:rnp_key_get_protection_type) + end + + # Get the mode in which the secret key data is encrypted. + # + # @return [String] one of 'None', 'Unknown', 'CFB', 'CBC' or 'OCB' + def protection_mode + string_property(:rnp_key_get_protection_mode) + end + + # Get the cipher used to encrypt the secret key data. + # + # @note This raises an error if the secret key data is not available + # or not encrypted. + # + # @return [String] + def protection_cipher + string_property(:rnp_key_get_protection_cipher) + end + + # Get the hash used to derive the secret-key-data encrypting key from + # the password. + # + # @note This raises an error if the secret key data is not available + # or not encrypted. + # + # @return [String] + def protection_hash + string_property(:rnp_key_get_protection_hash) + end + + # Get the number of iterations used to derive the encrypting key from + # the password. + # + # @note This raises an error if the secret key data is not available + # or not encrypted. + # + # @return [Integer] + def protection_iterations + piterations = FFI::MemoryPointer.new(:size_t) + Rnp.call_ffi(:rnp_key_get_protection_iterations, @ptr, piterations) + piterations.read(:size_t) + end + # Start building a certification signature over a userid, issued by # this key. Customize the returned {KeySignature} and call # {KeySignature#sign} to finalize it. diff --git a/spec/key/inspection_spec.rb b/spec/key/inspection_spec.rb new file mode 100644 index 0000000..496fc08 --- /dev/null +++ b/spec/key/inspection_spec.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +# (c) 2026 Ribose Inc. + +require 'spec_helper' + +describe 'key inspection' do + let(:rnp) do + rnp = Rnp.new + rnp.load_keys(format: 'GPG', + input: Rnp::Input.from_path('spec/data/keyrings/gpg/secring.gpg')) + rnp + end + let(:key) { rnp.find_key(userid: 'key0-uid1') } + + describe Rnp::Key.instance_method(:version), + skip: !LibRnp::HAVE_RNP_KEY_GET_VERSION do + it 'returns the key version' do + expect(key.version).to eql 4 + end + end + + describe Rnp::Key.instance_method(:expired?), + skip: !LibRnp::HAVE_RNP_KEY_IS_EXPIRED do + it 'returns false for a key without expiration' do + expect(key.expired?).to be false + end + end + + describe Rnp::Key.instance_method(:valid?), + skip: !LibRnp::HAVE_RNP_KEY_IS_VALID do + it 'returns true for a valid key' do + expect(key.valid?).to be true + end + + it 'returns false for a revoked key' do + rnp.load_keys(format: 'GPG', + input: Rnp::Input.from_path('spec/data/keys/ecc-p256-revoked-key.asc')) + revoked = rnp.find_key(userid: 'ecc-p256') + expect(revoked.valid?).to be false + end + end + + describe Rnp::Key.instance_method(:valid_till), + skip: !LibRnp::HAVE_RNP_KEY_VALID_TILL64 do + it 'returns nil for a key that never expires' do + expect(key.valid_till).to be_nil + end + + it 'returns a time for a key that is no longer valid' do + rnp.load_keys(format: 'GPG', + input: Rnp::Input.from_path('spec/data/keys/ecc-p256-revoked-key.asc')) + revoked = rnp.find_key(userid: 'ecc-p256') + expect(revoked.valid_till).to be_kind_of(Time) + end + + it 'returns the expiration time for an expiring key' do + op = rnp.start_generate(type: 'RSA') + op.bits = 1024 + op.userid = 'expiring' + op.lifetime = 3600 + op.execute + generated = op.key + expect(generated.valid_till.to_i) + .to eql(generated.creation_time.to_i + 3600) + end + end + + describe Rnp::Key.instance_method(:revokers), + skip: !LibRnp::HAVE_RNP_KEY_GET_REVOKER_COUNT do + it 'returns an empty list when there are no designated revokers' do + expect(key.revokers).to eql [] + end + end + + describe Rnp::Key.instance_method(:primary_fingerprint), + skip: !LibRnp::HAVE_RNP_KEY_GET_PRIMARY_FPRINT do + it 'returns the primary fingerprint for a subkey' do + subkey = key.subkeys[0] + expect(subkey.primary_fingerprint).to eql key.fingerprint + end + + it 'raises an error for a primary key' do + expect { key.primary_fingerprint }.to raise_error(Rnp::Error) + end + end + + describe Rnp::Key.instance_method(:revocation_signature), + skip: !LibRnp::HAVE_RNP_KEY_GET_REVOCATION_SIGNATURE do + it 'returns nil for a key that is not revoked' do + expect(key.revocation_signature).to be_nil + end + + it 'returns the revocation signature for a revoked key' do + rnp.load_keys(format: 'GPG', + input: Rnp::Input.from_path('spec/data/keys/ecc-p256-revoked-key.asc')) + revoked = rnp.find_key(userid: 'ecc-p256') + sig = revoked.revocation_signature + expect(sig).to be_a Rnp::Signature + expect(sig.signature_type).to eql 'key revocation' + end + end + + describe 'protection info', + skip: !LibRnp::HAVE_RNP_KEY_GET_PROTECTION_TYPE do + context 'protected key' do + it 'returns the protection type and mode' do + expect(key.protection_type).to eql 'Encrypted-Hashed' + expect(key.protection_mode).to eql 'CFB' + end + + it 'returns the cipher, hash and iterations' do + expect(key.protection_cipher).to eql 'CAST5' + expect(key.protection_hash).to eql 'SHA1' + expect(key.protection_iterations).to be > 0 + end + end + + context 'unprotected key' do + let(:key) do + op = rnp.start_generate(type: 'RSA') + op.bits = 1024 + op.userid = 'unprotected' + op.execute + op.key + end + + it 'returns None for the type and mode' do + expect(key.protection_type).to eql 'None' + expect(key.protection_mode).to eql 'None' + end + + it 'raises an error for the cipher, hash and iterations' do + expect { key.protection_cipher }.to raise_error(Rnp::Error) + expect { key.protection_hash }.to raise_error(Rnp::Error) + expect { key.protection_iterations }.to raise_error(Rnp::Error) + end + end + end +end From 9ec399afcfadf44b224107e89dfaa896d4979d3e Mon Sep 17 00:00:00 2001 From: Ronald Tse Date: Mon, 20 Jul 2026 01:44:44 +0800 Subject: [PATCH 06/14] Add misc utility bindings - Rnp.backend_string and Rnp.backend_version (crypto backend info) - Rnp#request_password (via the ffi password provider, securely clearing the buffer with rnp_buffer_clear) - Generate#request_password= (request key protection password via the password provider) - Encrypt#flags= (RNP_ENCRYPT_NOWRAP) - Output.to_armor, Output#armor_line_length= and Output#finish - Rnp.dump_packets (human-readable packet dump) --- lib/rnp/misc.rb | 36 ++++++++++++++++++++++++++ lib/rnp/op/encrypt.rb | 10 ++++++++ lib/rnp/op/generate.rb | 12 +++++++++ lib/rnp/output.rb | 39 ++++++++++++++++++++++++++++ lib/rnp/rnp.rb | 22 ++++++++++++++++ spec/encrypt/encrypt_spec.rb | 47 ++++++++++++++++++++++++++++++++++ spec/generate/generate_spec.rb | 24 +++++++++++++++++ spec/misc_spec.rb | 35 +++++++++++++++++++++++++ spec/output_spec.rb | 33 ++++++++++++++++++++++++ spec/rnp_spec.rb | 33 ++++++++++++++++++++++++ 10 files changed, 291 insertions(+) diff --git a/lib/rnp/misc.rb b/lib/rnp/misc.rb index 7762fa4..d1996ed 100644 --- a/lib/rnp/misc.rb +++ b/lib/rnp/misc.rb @@ -108,6 +108,21 @@ def self.version_string LibRnp.rnp_version_string end + # Get the name of the cryptographic backend library (e.g. 'Botan' or + # 'OpenSSL'). + # + # @return [String] + def self.backend_string + LibRnp.rnp_backend_string + end + + # Get the version of the cryptographic backend library. + # + # @return [String] + def self.backend_version + LibRnp.rnp_backend_version + end + # Get the detailed version of the rnp library as a string. # # @return [String] @@ -188,6 +203,27 @@ def self.parse(input:, mpi: false, raw: false, grip: false) end end + # Dump OpenPGP packet information in human-readable format. + # + # @param input [Input] the input to read the data + # @param output [Output] the output to write the dump to. If nil, the + # result will be returned directly as a String. + # @param mpi [Boolean] if true then MPIs will be included + # @param raw [Boolean] if true then raw bytes will be included + # @param grip [Boolean] if true then grips will be included + # @return [nil, String] + def self.dump_packets(input:, output: nil, mpi: false, raw: false, + grip: false) + flags = 0 + flags |= LibRnp::RNP_DUMP_MPI if mpi + flags |= LibRnp::RNP_DUMP_RAW if raw + flags |= LibRnp::RNP_DUMP_GRIP if grip + Output.default(output) do |output_| + Rnp.call_ffi(:rnp_dump_packets_to_output, input.ptr, output_.ptr, + flags) + end + end + # Calculate s2k iterations # # @param hash [String] the hash algorithm to use diff --git a/lib/rnp/op/encrypt.rb b/lib/rnp/op/encrypt.rb index 641f275..b7d24e6 100644 --- a/lib/rnp/op/encrypt.rb +++ b/lib/rnp/op/encrypt.rb @@ -173,6 +173,16 @@ def expiration_time=(expiration_time) Rnp.call_ffi(:rnp_op_encrypt_set_expiration_time, @ptr, expiration_time) end + # Set additional encryption flags. + # + # @param flags [Integer] OR-ed combination of the LibRnp::RNP_ENCRYPT_* + # constants. Currently only LibRnp::RNP_ENCRYPT_NOWRAP is supported: + # it disables wrapping the data in a literal data packet, allowing to + # encrypt already signed data. + def flags=(flags) + Rnp.call_ffi(:rnp_op_encrypt_set_flags, @ptr, flags) + end + # Execute the operation. # # This should only be called once. diff --git a/lib/rnp/op/generate.rb b/lib/rnp/op/generate.rb index c9fb730..33e0fba 100644 --- a/lib/rnp/op/generate.rb +++ b/lib/rnp/op/generate.rb @@ -114,6 +114,18 @@ def password=(password) Rnp.call_ffi(:rnp_op_generate_set_protection_password, @ptr, password) end + # Enable or disable password requesting via the ffi's password + # provider (see {Rnp#password_provider=}). The requested password will + # be used to protect the key. + # + # @note This is ignored if a password was set via {#password=}. + # + # @param request [Boolean] true to enable password requesting + # (default is false, i.e. the key is generated unencrypted) + def request_password=(request) + Rnp.call_ffi(:rnp_op_generate_set_request_password, @ptr, request) + end + # Set the protection mode for this key. # # @note This is only valid for keys saved in the G10 format. diff --git a/lib/rnp/output.rb b/lib/rnp/output.rb index d690bcb..b684341 100644 --- a/lib/rnp/output.rb +++ b/lib/rnp/output.rb @@ -86,6 +86,23 @@ def self.to_io(io) to_callback(io.method(:write)) end + # Create an Output that ASCII-armors data, writing the armored stream + # to another output. + # + # @param base [Output] the output the armored data will be written to. + # It is retained by the returned output, as the armored stream + # references it internally. + # @param type [String] the armor type ('message', 'public key', + # 'secret key', 'signature', 'cleartext') + # @return [Output] + def self.to_armor(base, type) + pptr = FFI::MemoryPointer.new(:pointer) + Rnp.call_ffi(:rnp_output_to_armor, base.ptr, pptr, type) + # pass base as the second argument so it is not garbage collected + # before this output + Output.new(pptr.read_pointer, base) + end + # Write to the output. # # @param strings [String] @@ -111,6 +128,28 @@ def string buf.read_bytes(len.read(:size_t)) unless buf.null? end + # Set the line length for armored output written to this output. + # + # @note This is only valid for outputs created via {.to_armor}. + # + # @param llen [Integer] the line length in characters (16..76) + # @return [void] + def armor_line_length=(llen) + Rnp.call_ffi(:rnp_output_armor_set_line_length, @ptr, llen) + end + + # Finish writing to the output. + # + # @note For most output types this is not needed (destruction of the + # output finishes it implicitly). It is useful to deterministically + # finalize an output without destroying it, e.g. to write out the + # trailer of an armored output created via {.to_armor}. + # + # @return [void] + def finish + Rnp.call_ffi(:rnp_output_finish, @ptr) + end + # @api private WRITER = lambda do |writer, _ctx, buf, buf_len| begin diff --git a/lib/rnp/rnp.rb b/lib/rnp/rnp.rb index efce98b..279a88d 100644 --- a/lib/rnp/rnp.rb +++ b/lib/rnp/rnp.rb @@ -263,6 +263,28 @@ def find_key(criteria) Rnp::Key.new(pkey) unless pkey.null? end + # Request a password via the configured password provider (see + # {#password_provider=}). + # + # @param context [String] string describing the purpose of the password + # request. Any custom value may be used, as far as the password + # provider handles it. + # @param key [Key, nil] the key for which the password is requested + # @return [String] the requested password + def request_password(context, key: nil) + pptr = FFI::MemoryPointer.new(:pointer) + Rnp.call_ffi(:rnp_request_password, @ptr, key&.ptr, context, pptr) + begin + ppassword = pptr.read_pointer + password = ppassword.read_string unless ppassword.null? + # securely clear the buffer before freeing it + LibRnp.rnp_buffer_clear(ppassword, password.bytesize) if password + password + ensure + LibRnp.rnp_buffer_destroy(ppassword) + end + end + # @!method userids # Get a list of all userids. # diff --git a/spec/encrypt/encrypt_spec.rb b/spec/encrypt/encrypt_spec.rb index 3be867b..8f81663 100644 --- a/spec/encrypt/encrypt_spec.rb +++ b/spec/encrypt/encrypt_spec.rb @@ -144,3 +144,50 @@ end end # expert encryption + +describe Rnp::Encrypt.instance_method(:flags=), + skip: !LibRnp::HAVE_RNP_OP_ENCRYPT_SET_FLAGS 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 + + it 'encrypts already-signed data without wrapping it in a literal data packet' do + key = rnp.find_key(userid: 'key0-uid1') + signed = rnp.sign(input: Rnp::Input.from_string('data'), + signers: [key], + armored: false) + + # without NOWRAP the signed message is treated as opaque data and + # wrapped in a literal data packet + output = Rnp::Output.to_string + encrypt = rnp.start_encrypt(input: Rnp::Input.from_string(signed), + output: output) + encrypt.add_recipient(key) + encrypt.execute + decrypted = Rnp::Output.to_string + verify = rnp.start_verify(input: Rnp::Input.from_string(output.string), + output: decrypted) + verify.execute + expect(decrypted.string).to eql signed + expect(verify.signatures).to be_empty + + # with NOWRAP the signed message is encrypted as-is, so its signature + # is verified after decryption + output = Rnp::Output.to_string + encrypt = rnp.start_encrypt(input: Rnp::Input.from_string(signed), + output: output) + encrypt.flags = LibRnp::RNP_ENCRYPT_NOWRAP + encrypt.add_recipient(key) + encrypt.execute + decrypted = Rnp::Output.to_string + verify = rnp.start_verify(input: Rnp::Input.from_string(output.string), + output: decrypted) + verify.execute + expect(decrypted.string).to eql 'data' + expect(verify.good?).to be true + end +end diff --git a/spec/generate/generate_spec.rb b/spec/generate/generate_spec.rb index 744b36d..0d8b95e 100644 --- a/spec/generate/generate_spec.rb +++ b/spec/generate/generate_spec.rb @@ -344,3 +344,27 @@ end end end + +describe Rnp::Generate.instance_method(:request_password=), + skip: !LibRnp::HAVE_RNP_OP_GENERATE_SET_REQUEST_PASSWORD do + it "requests the password via the password provider" do + rnp = Rnp.new + rnp.password_provider = "gen-password" + op = rnp.start_generate(type: "RSA") + op.bits = 1024 + op.userid = "protected-gen" + op.request_password = true + op.execute + expect(op.key.protected?).to be true + expect(op.key.unlock("gen-password").locked?).to be false + end + + it "generates an unprotected key by default" do + rnp = Rnp.new + op = rnp.start_generate(type: "RSA") + op.bits = 1024 + op.userid = "unprotected-gen" + op.execute + expect(op.key.protected?).to be false + end +end diff --git a/spec/misc_spec.rb b/spec/misc_spec.rb index 01a6e36..b3fed08 100644 --- a/spec/misc_spec.rb +++ b/spec/misc_spec.rb @@ -224,3 +224,38 @@ expect(Rnp.supported_features('symmetric algorithm').class).to be Array end end + +describe Rnp.method(:backend_string), + skip: !LibRnp::HAVE_RNP_BACKEND_STRING do + it 'returns the crypto backend name' do + expect(%w[Botan OpenSSL]).to include Rnp.backend_string + end +end + +describe Rnp.method(:backend_version), + skip: !LibRnp::HAVE_RNP_BACKEND_VERSION do + it 'returns the crypto backend version' do + expect(Rnp.backend_version).to be_kind_of(String) + expect(Rnp.backend_version).to_not be_empty + end +end + +describe Rnp.method(:dump_packets), + skip: !LibRnp::HAVE_RNP_DUMP_PACKETS_TO_OUTPUT do + it 'dumps packet information in human-readable format' do + dump = Rnp.dump_packets( + input: Rnp::Input.from_path('spec/data/keyrings/gpg/pubring.gpg') + ) + expect(dump).to be_kind_of(String) + expect(dump).to include 'packet header' + end + + it 'writes to the given output' do + output = Rnp::Output.to_string + Rnp.dump_packets( + input: Rnp::Input.from_path('spec/data/keyrings/gpg/pubring.gpg'), + output: output + ) + expect(output.string).to include 'packet header' + end +end diff --git a/spec/output_spec.rb b/spec/output_spec.rb index 510dda6..69c04a4 100644 --- a/spec/output_spec.rb +++ b/spec/output_spec.rb @@ -24,3 +24,36 @@ expect(output.string).to eql '1234567890abcde' end end + +describe Rnp::Output.method(:to_armor), + skip: !LibRnp::HAVE_RNP_OUTPUT_TO_ARMOR do + it 'armors the data written to it' do + base = Rnp::Output.to_string + armored = Rnp::Output.to_armor(base, 'message') + armored.write('my data') + armored.finish + expect(base.string.start_with?("-----BEGIN PGP MESSAGE-----\r\n")).to be true + expect(base.string.end_with?("-----END PGP MESSAGE-----\r\n")).to be true + end + + describe Rnp::Output.instance_method(:armor_line_length=), + skip: !LibRnp::HAVE_RNP_OUTPUT_ARMOR_SET_LINE_LENGTH do + it 'sets the line length' do + base = Rnp::Output.to_string + armored = Rnp::Output.to_armor(base, 'message') + armored.armor_line_length = 24 + armored.write('x' * 100) + armored.finish + payload = base.string.lines.reject do |line| + line.start_with?('-----') || line.strip.empty? + end + expect(payload.map { |line| line.chomp.size }.max).to be <= 24 + end + + it 'raises an error for a non-armor output' do + expect do + Rnp::Output.to_string.armor_line_length = 24 + end.to raise_error(Rnp::Error) + end + end +end diff --git a/spec/rnp_spec.rb b/spec/rnp_spec.rb index 9dcfa3a..416d7da 100644 --- a/spec/rnp_spec.rb +++ b/spec/rnp_spec.rb @@ -427,3 +427,36 @@ end # generate_key end + +describe Rnp.instance_method(:request_password), + skip: !LibRnp::HAVE_RNP_REQUEST_PASSWORD do + let(:rnp) do + rnp = Rnp.new + rnp.load_keys(format: 'GPG', + input: Rnp::Input.from_path('spec/data/keyrings/gpg/secring.gpg')) + rnp + end + + it 'requests a password via the password provider' do + received = nil + rnp.password_provider = lambda do |key, reason| + received = [key&.keyid, reason] + 'the-password' + end + key = rnp.find_key(userid: 'key0-uid1') + expect(rnp.request_password('test context', key: key)).to eql 'the-password' + expect(received).to eql [key.keyid, 'test context'] + end + + it 'works without a key' do + rnp.password_provider = 'literal-password' + expect(rnp.request_password('custom context')).to eql 'literal-password' + end + + it 'raises an error when no password is provided' do + rnp.password_provider = nil + expect do + rnp.request_password('custom context') + end.to raise_error(Rnp::Error) + end +end From f9e4d16315f840fbe2ce9756245a77e040104464 Mon Sep 17 00:00:00 2001 From: Ronald Tse Date: Mon, 20 Jul 2026 03:52:44 +0800 Subject: [PATCH 07/14] Fix use-after-free in Output.to_armor finalization rnp_output_destroy() accesses the base output when destroying an armored output (propagating the keep flag and writing the armor trailer), so the armored output must be destroyed first. With two independent AutoPointers the GC could finalize the base output first, corrupting the heap (observed as glibc malloc aborts in CI, e.g. in the GC-forcing z_spec). Coordinate the two finalizers via a shared state so the armored output is always destroyed before the base output, exactly once, while keeping the base output usable on its own. --- lib/rnp/output.rb | 49 ++++++++++++++++++++++++++++++++++++++++++--- spec/output_spec.rb | 23 +++++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/lib/rnp/output.rb b/lib/rnp/output.rb index b684341..b8934c1 100644 --- a/lib/rnp/output.rb +++ b/lib/rnp/output.rb @@ -98,9 +98,52 @@ def self.to_io(io) def self.to_armor(base, type) pptr = FFI::MemoryPointer.new(:pointer) Rnp.call_ffi(:rnp_output_to_armor, base.ptr, pptr, type) - # pass base as the second argument so it is not garbage collected - # before this output - Output.new(pptr.read_pointer, base) + base_ptr = base.instance_variable_get(:@ptr) + # The armored output references the base output internally, and + # librnp accesses the base output when the armored output is + # destroyed (see rnp_output_destroy()). The armored output must + # therefore always be destroyed before the base output, regardless + # of the order in which the garbage collector finalizes the two + # objects. The finalizers below coordinate this via a shared state. + state = { armored: pptr.read_pointer } + base_ptr.autorelease = false + base.instance_variable_set( + :@ptr, + FFI::AutoPointer.new(FFI::Pointer.new(base_ptr.address), + base_finalizer(state)) + ) + output = allocate + output.instance_variable_set( + :@ptr, FFI::AutoPointer.new(state[:armored], armored_finalizer(state)) + ) + output.instance_variable_set(:@writer, base) + output + end + + # @api private + # Finalizer for an armored output created via {.to_armor}: destroys + # the armored output (once). + def self.armored_finalizer(state) + lambda do |ptr| + if state[:armored] + LibRnp.rnp_output_destroy(ptr) + state[:armored] = nil + end + end + end + + # @api private + # Finalizer for the base output of an armored output created via + # {.to_armor}: destroys the armored output first (if it still + # exists), then the base output. + def self.base_finalizer(state) + lambda do |ptr| + if state[:armored] + LibRnp.rnp_output_destroy(state[:armored]) + state[:armored] = nil + end + LibRnp.rnp_output_destroy(ptr) + end end # Write to the output. diff --git a/spec/output_spec.rb b/spec/output_spec.rb index 69c04a4..d51d976 100644 --- a/spec/output_spec.rb +++ b/spec/output_spec.rb @@ -36,6 +36,29 @@ expect(base.string.end_with?("-----END PGP MESSAGE-----\r\n")).to be true end + it 'survives garbage collection in any finalization order' do + # the armored output must be finalized before its base output + # (regression test for a use-after-free) + 200.times do + base = Rnp::Output.to_string + armored = Rnp::Output.to_armor(base, 'message') + armored.write('x' * 100) + armored.finish + end + GC.start + end + + it 'keeps the base output usable after the armored one is finalized' do + base = Rnp::Output.to_string + armored = Rnp::Output.to_armor(base, 'message') + armored.write('my data') + armored.finish + text = base.string + armored = nil + GC.start + expect(base.string).to eql text + end + describe Rnp::Output.instance_method(:armor_line_length=), skip: !LibRnp::HAVE_RNP_OUTPUT_ARMOR_SET_LINE_LENGTH do it 'sets the line length' do From 727bceeb5087a7ad54187894e9514da6da91404c Mon Sep 17 00:00:00 2001 From: Ronald Tse Date: Mon, 20 Jul 2026 03:52:44 +0800 Subject: [PATCH 08/14] Retain operation inputs and outputs until execute Sign, Verify and Encrypt operations did not reference their input and output objects, so a caller creating an operation with temporary Input/Output objects could have them garbage collected before execute(), resulting in a use-after-free read (observed under GC.stress). --- lib/rnp/op/encrypt.rb | 6 +++++- lib/rnp/op/sign.rb | 6 +++++- lib/rnp/op/verify.rb | 6 +++++- lib/rnp/rnp.rb | 6 +++--- spec/rnp_spec.rb | 37 +++++++++++++++++++++++++++++++++++++ 5 files changed, 55 insertions(+), 6 deletions(-) diff --git a/lib/rnp/op/encrypt.rb b/lib/rnp/op/encrypt.rb index b7d24e6..928e87c 100644 --- a/lib/rnp/op/encrypt.rb +++ b/lib/rnp/op/encrypt.rb @@ -17,9 +17,13 @@ class Encrypt attr_reader :ptr # @api private - def initialize(ptr) + def initialize(ptr, input = nil, output = nil) raise Rnp::Error, 'NULL pointer' if ptr.null? @ptr = FFI::AutoPointer.new(ptr, self.class.method(:destroy)) + # retain the input and output so they are not garbage collected + # before the operation is executed + @input = input + @output = output end # @api private diff --git a/lib/rnp/op/sign.rb b/lib/rnp/op/sign.rb index 57d09cb..8f522d2 100644 --- a/lib/rnp/op/sign.rb +++ b/lib/rnp/op/sign.rb @@ -15,9 +15,13 @@ class Sign attr_reader :ptr # @api private - def initialize(ptr) + def initialize(ptr, input = nil, output = nil) raise Rnp::Error, 'NULL pointer' if ptr.null? @ptr = FFI::AutoPointer.new(ptr, self.class.method(:destroy)) + # retain the input and output so they are not garbage collected + # before the operation is executed + @input = input + @output = output end # @api private diff --git a/lib/rnp/op/verify.rb b/lib/rnp/op/verify.rb index 7490be3..30ebfea 100644 --- a/lib/rnp/op/verify.rb +++ b/lib/rnp/op/verify.rb @@ -15,9 +15,13 @@ class Verify attr_reader :ptr # @api private - def initialize(ptr) + def initialize(ptr, input = nil, output = nil) raise Rnp::Error, 'NULL pointer' if ptr.null? @ptr = FFI::AutoPointer.new(ptr, self.class.method(:destroy)) + # retain the input and output so they are not garbage collected + # before the operation is executed + @input = input + @output = output @signatures = nil end diff --git a/lib/rnp/rnp.rb b/lib/rnp/rnp.rb index 279a88d..c3c8d4e 100644 --- a/lib/rnp/rnp.rb +++ b/lib/rnp/rnp.rb @@ -642,7 +642,7 @@ def start_encrypt(input:, output:) pptr = FFI::MemoryPointer.new(:pointer) Rnp.call_ffi(:rnp_op_encrypt_create, pptr, @ptr, input.ptr, output.ptr) pencrypt = pptr.read_pointer - Encrypt.new(pencrypt) unless pencrypt.null? + Encrypt.new(pencrypt, input, output) unless pencrypt.null? end # Import keys @@ -716,14 +716,14 @@ def _start_sign(func, input, output) pptr = FFI::MemoryPointer.new(:pointer) Rnp.call_ffi(func, pptr, @ptr, input.ptr, output.ptr) psign = pptr.read_pointer - Rnp::Sign.new(psign) unless psign.null? + Rnp::Sign.new(psign, input, output) unless psign.null? end def _start_verify(func, io1, io2) pptr = FFI::MemoryPointer.new(:pointer) Rnp.call_ffi(func, pptr, @ptr, io1.ptr, io2.ptr) pverify = pptr.read_pointer - Verify.new(pverify) unless pverify.null? + Verify.new(pverify, io1, io2) unless pverify.null? end def simple_encrypt(enc, recipients: nil, signers: nil) diff --git a/spec/rnp_spec.rb b/spec/rnp_spec.rb index 416d7da..bdde33f 100644 --- a/spec/rnp_spec.rb +++ b/spec/rnp_spec.rb @@ -460,3 +460,40 @@ end.to raise_error(Rnp::Error) end end + +describe 'operation input/output lifetime' 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(:key) { rnp.find_key(userid: 'key0-uid1') } + + it 'retains the input and output of a verify operation until execute' do + signature = rnp.sign(input: Rnp::Input.from_string('data'), + signers: [key], + armored: false) + verify = rnp.start_verify(input: Rnp::Input.from_string(signature), + output: Rnp::Output.to_null) + GC.start + expect { verify.execute }.to_not raise_error + end + + it 'retains the input and output of a sign operation until execute' do + sign = rnp.start_sign(input: Rnp::Input.from_string('data'), + output: Rnp::Output.to_string) + sign.add_signer(key) + GC.start + expect { sign.execute }.to_not raise_error + end + + it 'retains the input and output of an encrypt operation until execute' do + encrypt = rnp.start_encrypt(input: Rnp::Input.from_string('data'), + output: Rnp::Output.to_string) + encrypt.add_recipient(key) + GC.start + expect { encrypt.execute }.to_not raise_error + end +end From 8bf96a4b78ae2637a2c8074f4a4807e590e5d67f Mon Sep 17 00:00:00 2001 From: Ronald Tse Date: Mon, 20 Jul 2026 03:52:44 +0800 Subject: [PATCH 09/14] Make new specs compatible with librnp 0.17.x The signature removal specs are set up via the key certification API (0.18+), but their inner 'skip: false' metadata (the removal API itself exists in 0.17.x) overrode the outer skip and ran them anyway, raising FeatureNotAvailableError. Drop the inner skip metadata. Also, rnp_signature_is_valid() only learnt to return the validation status of document signatures and to revalidate key signatures (RNP_SIGNATURE_REVALIDATE) in 0.18.0. Gate the affected specs on the new 'signature-validity-status' feature. --- lib/rnp/misc.rb | 5 +++++ spec/key_signature_spec.rb | 11 +++++++---- spec/signature_spec.rb | 6 ++++-- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/lib/rnp/misc.rb b/lib/rnp/misc.rb index d1996ed..9d4494c 100644 --- a/lib/rnp/misc.rb +++ b/lib/rnp/misc.rb @@ -324,6 +324,11 @@ def self.supported_features(type) # Now at least one valid signature is required for success "require-single-valid-signature" => Rnp.version >= Rnp.version("0.16.1") || Rnp.commit_time >= 1661781294, + # rnp_signature_is_valid() was extended to return the validation status + # of document signatures and to revalidate key signatures + # (RNP_SIGNATURE_REVALIDATE). + "signature-validity-status" => Rnp.version >= Rnp.version("0.18.0") || + Rnp.commit_time >= 1742391792, }.freeze def self.has?(feature) diff --git a/spec/key_signature_spec.rb b/spec/key_signature_spec.rb index d6953c0..7510238 100644 --- a/spec/key_signature_spec.rb +++ b/spec/key_signature_spec.rb @@ -131,8 +131,12 @@ def generate(rnp, userid) signer.start_certification(target.uids[0]).sign end - describe Rnp::Key.instance_method(:remove_signature), - skip: !LibRnp::HAVE_RNP_SIGNATURE_REMOVE do + # Note: no per-method skip metadata here. The setup above requires + # the key certification API (librnp 0.18+), which gates the whole + # file; an inner 'skip: false' (e.g. when the removal API is present + # but the certification API is not, as in librnp 0.17.x) would + # override the outer skip and run the example anyway. + describe Rnp::Key.instance_method(:remove_signature) do it 'removes a single signature' do sig = target.uids[0].signatures.find { |s| s.keyid == signer.keyid } expect(sig).to_not be_nil @@ -142,8 +146,7 @@ def generate(rnp, userid) end end - describe Rnp::Key.instance_method(:remove_signatures), - skip: !LibRnp::HAVE_RNP_KEY_REMOVE_SIGNATURES do + describe Rnp::Key.instance_method(:remove_signatures) do it 'removes non-self signatures' do expect(target.uids[0].signatures.map(&:keyid)) .to include signer.keyid diff --git a/spec/signature_spec.rb b/spec/signature_spec.rb index 841dcec..422f0b9 100644 --- a/spec/signature_spec.rb +++ b/spec/signature_spec.rb @@ -192,7 +192,8 @@ def verify_errors(data, signature) expect(sig.valid?).to be true end - it 'revalidates the signature' do + it 'revalidates the signature', + skip: !Rnp.has?('signature-validity-status') do expect(sig.valid?(revalidate: true)).to be true end end @@ -272,7 +273,8 @@ def verify_errors(data, signature) expect(sig.fingerprint).to eql key.fingerprint end - it 'is valid' do + it 'is valid', + skip: !Rnp.has?('signature-validity-status') do expect(sig.valid?).to be true end From 84e32820f07fdcbcedeb364c086c7852daad9e55 Mon Sep 17 00:00:00 2001 From: Ronald Tse Date: Mon, 20 Jul 2026 07:29:59 +0800 Subject: [PATCH 10/14] Attach the remaining librnp FFI functions Attach the last 23 exported-but-unbound librnp functions, completing FFI parity: the uid API, key lifecycle (revoke, expiration, default key, autocrypt/revocation export), op file metadata and protection info, I/O stream helpers and Curve25519 bit tweaking, along with the related constants. Symbols newer than the minimum supported librnp (0.15.2) - the stdio helpers and 25519 tweaking - go through the optional-symbol probing. --- lib/rnp/ffi/librnp.rb | 76 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/lib/rnp/ffi/librnp.rb b/lib/rnp/ffi/librnp.rb index 73c1bdf..2ad8146 100644 --- a/lib/rnp/ffi/librnp.rb +++ b/lib/rnp/ffi/librnp.rb @@ -238,6 +238,12 @@ module LibRnp rnp_output_finish: [%i[pointer], :uint32], # packet dumping (human-readable) rnp_dump_packets_to_output: [%i[pointer pointer uint32], :uint32], + # stdio convenience streams + rnp_input_from_stdin: [%i[pointer], :uint32], + rnp_output_to_stdout: [%i[pointer], :uint32], + # Curve25519 secret key bit tweaking + rnp_key_25519_bits_tweak: [%i[pointer], :uint32], + rnp_key_25519_bits_tweaked: [%i[pointer pointer], :uint32], }.each do |name, signature| present = !ffi_libraries[0].find_function(name.to_s).nil? if !present @@ -329,6 +335,39 @@ def self.rnp_version_commit_timestamp attach_function :rnp_key_handle_destroy, %i[pointer], :uint32 + attach_function :rnp_uid_get_data, + %i[pointer pointer pointer], + :uint32 + attach_function :rnp_uid_get_type, + %i[pointer pointer], + :uint32 + attach_function :rnp_uid_is_primary, + %i[pointer pointer], + :uint32 + attach_function :rnp_uid_is_valid, + %i[pointer pointer], + :uint32 + attach_function :rnp_uid_get_revocation_signature, + %i[pointer pointer], + :uint32 + attach_function :rnp_uid_remove, + %i[pointer pointer], + :uint32 + attach_function :rnp_key_revoke, + %i[pointer uint32 string string string], + :uint32 + attach_function :rnp_key_set_expiration, + %i[pointer uint32], + :uint32 + attach_function :rnp_key_get_default_key, + %i[pointer string uint32 pointer], + :uint32 + attach_function :rnp_key_export_autocrypt, + %i[pointer pointer string pointer uint32], + :uint32 + attach_function :rnp_key_export_revocation, + %i[pointer pointer uint32 string string string], + :uint32 attach_function :rnp_generate_key_json, %i[pointer string pointer], :uint32 @@ -407,6 +446,12 @@ def self.rnp_version_commit_timestamp attach_function :rnp_op_sign_set_compression, %i[pointer string int], :uint32 + attach_function :rnp_op_sign_set_file_name, + %i[pointer string], + :uint32 + attach_function :rnp_op_sign_set_file_mtime, + %i[pointer uint32], + :uint32 attach_function :rnp_op_sign_set_armor, %i[pointer bool], :uint32 @@ -443,6 +488,9 @@ def self.rnp_version_commit_timestamp attach_function :rnp_op_verify_get_file_info, %i[pointer pointer pointer], :uint32 + attach_function :rnp_op_verify_get_protection_info, + %i[pointer pointer pointer pointer], + :uint32 attach_function :rnp_op_verify_destroy, %i[pointer], :uint32 @@ -488,6 +536,12 @@ def self.rnp_version_commit_timestamp attach_function :rnp_output_to_null, %i[pointer], :uint32 + attach_function :rnp_output_to_file, + %i[pointer string uint32], + :uint32 + attach_function :rnp_output_pipe, + %i[pointer pointer], + :uint32 attach_function :rnp_output_destroy, %i[pointer], :uint32 @@ -521,6 +575,15 @@ def self.rnp_version_commit_timestamp attach_function :rnp_op_encrypt_set_compression, %i[pointer string int], :uint32 + attach_function :rnp_op_encrypt_set_file_name, + %i[pointer string], + :uint32 + attach_function :rnp_op_encrypt_set_file_mtime, + %i[pointer uint32], + :uint32 + attach_function :rnp_op_encrypt_set_aead_bits, + %i[pointer int], + :uint32 attach_function :rnp_op_encrypt_execute, %i[pointer], :uint32 @@ -553,6 +616,18 @@ def self.rnp_version_commit_timestamp RNP_KEY_EXPORT_PUBLIC = (1 << 1) RNP_KEY_EXPORT_SECRET = (1 << 2) RNP_KEY_EXPORT_SUBKEYS = (1 << 3) + RNP_KEY_EXPORT_BASE64 = (1 << 9) + + # flags for rnp_output_to_file + RNP_OUTPUT_FILE_OVERWRITE = (1 << 0) + RNP_OUTPUT_FILE_RANDOM = (1 << 1) + + # flags for default key selection + RNP_KEY_SUBKEYS_ONLY = (1 << 0) + + # user id types + RNP_USER_ID = 1 + RNP_USER_ATTR = 2 RNP_LOAD_SAVE_PUBLIC_KEYS = (1 << 0) RNP_LOAD_SAVE_SECRET_KEYS = (1 << 1) @@ -625,6 +700,7 @@ def self.rnp_version_commit_timestamp RNP_ERROR_BAD_FORMAT = 0x10000001 RNP_ERROR_SIGNATURE_INVALID = 0x12000002 RNP_ERROR_BAD_PASSWORD = 0x12000004 + RNP_ERROR_KEY_NOT_FOUND = 0x12000005 RNP_ERROR_NO_SUITABLE_KEY = 0x12000006 RNP_ERROR_SIGNATURE_EXPIRED = 0x1200000B end # module From 44e1cfe45fd0d5271f8c21ff7e87b54bd895be6c Mon Sep 17 00:00:00 2001 From: Ronald Tse Date: Mon, 20 Jul 2026 07:30:20 +0800 Subject: [PATCH 11/14] Model the full userid surface in Rnp::UserID Extend the UserID object with data, type, primary?, valid?, revocation_signature and remove (wraps rnp_uid_remove). The uid handle references the owning key internally, so the key is now retained by the UserID to prevent use-after-free if the key object is garbage collected first. --- lib/rnp/key.rb | 2 +- lib/rnp/userid.rb | 92 +++++++++++++++++++++++++++++---- spec/userid_spec.rb | 122 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 206 insertions(+), 10 deletions(-) create mode 100644 spec/userid_spec.rb diff --git a/lib/rnp/key.rb b/lib/rnp/key.rb index 713ab39..73b1a4c 100644 --- a/lib/rnp/key.rb +++ b/lib/rnp/key.rb @@ -709,7 +709,7 @@ def uid_iterator next if phandle.nil? Rnp.call_ffi(:rnp_key_get_uid_at, @ptr, i, pptr) puserid = pptr.read_pointer - yield UserID.new(phandle, puserid.read_string) unless puserid.null? + yield UserID.new(phandle, puserid.read_string, self) unless puserid.null? phandle = nil ensure LibRnp.rnp_uid_handle_destroy(phandle) diff --git a/lib/rnp/userid.rb b/lib/rnp/userid.rb index 78d3380..289fc13 100644 --- a/lib/rnp/userid.rb +++ b/lib/rnp/userid.rb @@ -16,10 +16,13 @@ class UserID attr_reader :ptr # @api private - def initialize(ptr, userid) + def initialize(ptr, userid, key = nil) raise Rnp::Error, 'NULL pointer' if ptr.null? @ptr = FFI::AutoPointer.new(ptr, self.class.method(:destroy)) @userid = userid + # the uid handle references the owning key internally, so the key + # must be retained while this handle is alive + @key = key end # @api private @@ -35,7 +38,69 @@ def to_s @userid end - # Enumerate each {Signature} for this key. + # Get the userid's data. Its representation depends on {#type}: for + # LibRnp::RNP_USER_ID it is the userid string, for + # LibRnp::RNP_USER_ATTR it is the binary attribute data (e.g. a + # photo). + # + # @return [String] the raw userid data (binary encoding) + def data + pptr = FFI::MemoryPointer.new(:pointer) + psize = FFI::MemoryPointer.new(:size_t) + Rnp.call_ffi(:rnp_uid_get_data, @ptr, pptr, psize) + begin + pdata = pptr.read_pointer + pdata.read_bytes(psize.read(:size_t)) unless pdata.null? + ensure + LibRnp.rnp_buffer_destroy(pdata) + end + end + + # Get the userid's type. + # + # @return [Integer] LibRnp::RNP_USER_ID for a regular userid (name + # and email) or LibRnp::RNP_USER_ATTR for a binary attribute + # (e.g. a photo) + def type + ptype = FFI::MemoryPointer.new(:uint32) + Rnp.call_ffi(:rnp_uid_get_type, @ptr, ptype) + ptype.read(:uint32) + end + + # Check whether the userid is marked as primary. + # + # @return [Boolean] + def primary? + bool_property(:rnp_uid_is_primary) + end + + # Check whether the userid is valid. A userid is considered valid if + # the key itself is valid and the userid has at least one valid, + # non-expired self-certification. + # + # @return [Boolean] + def valid? + bool_property(:rnp_uid_is_valid) + end + + # Check if this userid is revoked. + # + # @return [Boolean] + def revoked? + bool_property(:rnp_uid_is_revoked) + end + + # Get the userid's revocation signature, if any. + # + # @return [Signature, nil] nil if the userid is not revoked + def revocation_signature + pptr = FFI::MemoryPointer.new(:pointer) + Rnp.call_ffi(:rnp_uid_get_revocation_signature, @ptr, pptr) + psig = pptr.read_pointer + Signature.new(psig) unless psig.null? + end + + # Enumerate each {Signature} for this userid. # # @return [self, Enumerator] def each_signature(&block) @@ -44,24 +109,33 @@ def each_signature(&block) self end - # Get a list of all {Signature}s for this key. + # Get a list of all {Signature}s for this userid. # # @return [Array] def signatures each_signature.to_a end - # Check if this key is revoked. + # Remove the userid with all of its signatures from the key. # - # @return [Boolean] - def revoked? - presult = FFI::MemoryPointer.new(:bool) - Rnp.call_ffi(:rnp_uid_is_revoked, @ptr, presult) - presult.read(:bool) + # @note This handle must still be destroyed afterwards (which happens + # automatically), but it, as well as any other handles pointing to + # the same userid, must not be used anymore. + # + # @return [void] + def remove + raise Rnp::Error, 'no owning key available' if @key.nil? + Rnp.call_ffi(:rnp_uid_remove, @key.ptr, @ptr) end private + def bool_property(func) + presult = FFI::MemoryPointer.new(:bool) + Rnp.call_ffi(func, @ptr, presult) + presult.read(:bool) + end + def signature_iterator pcount = FFI::MemoryPointer.new(:size_t) Rnp.call_ffi(:rnp_uid_get_signature_count, @ptr, pcount) diff --git a/spec/userid_spec.rb b/spec/userid_spec.rb new file mode 100644 index 0000000..bb62ce0 --- /dev/null +++ b/spec/userid_spec.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +# (c) 2026 Ribose Inc. + +require 'spec_helper' + +describe Rnp::UserID 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(:key) { rnp.find_key(userid: 'key0-uid1') } + let(:uid) { key.uids[0] } + + describe Rnp::UserID.instance_method(:data) do + it 'returns the userid string for a regular userid' do + expect(uid.data).to eql 'key0-uid0' + end + end + + describe Rnp::UserID.instance_method(:type) do + it 'returns the userid type' do + expect(uid.type).to eql LibRnp::RNP_USER_ID + end + end + + describe Rnp::UserID.instance_method(:primary?) do + it 'returns whether the userid is primary' do + expect(uid.primary?).to be false + end + end + + describe Rnp::UserID.instance_method(:valid?) do + it 'returns true for a valid userid' do + expect(uid.valid?).to be true + end + end + + describe Rnp::UserID.instance_method(:revoked?) do + it 'returns false for a non-revoked userid' do + expect(uid.revoked?).to be false + end + end + + describe Rnp::UserID.instance_method(:revocation_signature) do + it 'returns nil for a non-revoked userid' do + expect(uid.revocation_signature).to be_nil + end + end + + context 'ecc-p256-revoked-uid' do + let(:rnp) do + rnp = Rnp.new + rnp.load_keys( + format: 'GPG', + input: Rnp::Input.from_path('spec/data/keys/ecc-p256-revoked-uid.asc') + ) + rnp + end + let(:key) { rnp.find_key(userid: 'ecc-p256') } + + it 'reports the revoked userid' do + revoked = key.uids.find { |u| u.to_s == 'ecc-p256-revoked' } + expect(revoked.revoked?).to be true + expect(revoked.valid?).to be false + end + + it 'returns the revocation signature' do + sig = key.uids.find { |u| u.to_s == 'ecc-p256-revoked' } + .revocation_signature + expect(sig).to be_a Rnp::Signature + expect(sig.signature_type).to eql 'certification revocation' + end + + it 'keeps the non-revoked userid valid' do + good = key.uids.find { |u| u.to_s == 'ecc-p256' } + expect(good.revoked?).to be false + expect(good.valid?).to be true + end + end + + describe 'primary userid' do + it 'reports the primary flag' do + op = rnp.start_generate(type: 'RSA') + op.bits = 1024 + op.userid = 'first-uid' + op.execute + generated = op.key + expect(generated.uids[0].primary?).to be false + generated.add_userid('second-uid', primary: true) + expect(generated.uids.find { |u| u.to_s == 'second-uid' }.primary?) + .to be true + end + end + + describe Rnp::UserID.instance_method(:remove) do + it 'removes the userid from the key' do + op = rnp.start_generate(type: 'RSA') + op.bits = 1024 + op.userid = 'first-uid' + op.execute + generated = op.key + generated.add_userid('second-uid') + expect(generated.uids.map(&:to_s)).to eql %w[first-uid second-uid] + generated.uids.find { |u| u.to_s == 'second-uid' }.remove + expect(generated.uids.map(&:to_s)).to eql %w[first-uid] + end + end + + describe 'key handle lifetime' do + it 'keeps the owning key alive while the uid is in use' do + # the uid handle references the key internally; the key object + # must not be garbage collected while the uid is alive + uid = rnp.find_key(userid: 'key0-uid1').uids[0] + GC.start + expect(uid.valid?).to be true + end + end +end From 99bc7a739dbe416964f60e7b0e5647ca3f9ff25e Mon Sep 17 00:00:00 2001 From: Ronald Tse Date: Mon, 20 Jul 2026 07:30:42 +0800 Subject: [PATCH 12/14] Add key lifecycle methods - Key#revoke (with optional hash, reason code and text) - Key#set_expiration - Key#default_key (usage-based default key/subkey selection) - Key#export_autocrypt (minimal Autocrypt export, binary or base64) - Key#export_revocation (standalone revocation signature export) - Key#x25519_bits_tweak / #x25519_bits_tweaked? (advanced Curve25519 secret key bit tweaking; the latter returns true when the bits are correctly set) --- lib/rnp/key.rb | 123 ++++++++++++++ spec/data/keys/key-25519-non-tweaked-sec.asc | 13 ++ spec/key/lifecycle_spec.rb | 160 +++++++++++++++++++ 3 files changed, 296 insertions(+) create mode 100644 spec/data/keys/key-25519-non-tweaked-sec.asc create mode 100644 spec/key/lifecycle_spec.rb diff --git a/lib/rnp/key.rb b/lib/rnp/key.rb index 73b1a4c..40daa32 100644 --- a/lib/rnp/key.rb +++ b/lib/rnp/key.rb @@ -648,6 +648,129 @@ def remove_signature(signature) Rnp.call_ffi(:rnp_signature_remove, @ptr, signature.ptr) end + # Revoke the key (or subkey) by generating and adding a revocation + # signature. + # + # @note For a primary key the secret key must be available (otherwise + # the keyrings are searched for a secret key authorized to issue + # revocation signatures). For a subkey the primary secret key must + # be available. If the secret key is locked, the password will be + # asked via the password provider. + # + # @param hash [String, nil] the hash algorithm to use for the + # signature (nil for the default) + # @param code [String, nil] the revocation reason code: 'no', + # 'superseded', 'compromised' or 'retired' (nil for 'no') + # @param reason [String, nil] the human-readable revocation reason + # @return [void] + def revoke(hash: nil, code: nil, reason: nil) + Rnp.call_ffi(:rnp_key_revoke, @ptr, 0, hash, code, reason) + end + + # Set the key's expiration time. This requires re-signing, so the + # secret key (or, for a subkey, the secret primary key) must be + # available. If the secret key is locked, the password will be asked + # via the password provider. + # + # @param seconds [Integer] the expiration time in seconds, calculated + # from the key creation time (0 if the key doesn't expire) + # @return [void] + def set_expiration(seconds) + Rnp.call_ffi(:rnp_key_set_expiration, @ptr, seconds) + end + + # Get the default key for the specified usage. Accepts a primary key + # and returns one of its subkeys suitable for the desired usage (or + # the primary key itself, if suitable). + # + # @param usage [String, Symbol] the desired key usage ('sign', + # 'certify', 'encrypt', etc) + # @param subkeys_only [Boolean] if true, only subkeys are considered; + # otherwise the primary key may be returned if it is suitable + # @return [Key, nil] nil if no key with the desired usage was found + def default_key(usage, subkeys_only: false) + flags = subkeys_only ? LibRnp::RNP_KEY_SUBKEYS_ONLY : 0 + pptr = FFI::MemoryPointer.new(:pointer) + rc = LibRnp.rnp_key_get_default_key(@ptr, usage.to_s, flags, pptr) + if [LibRnp::RNP_ERROR_KEY_NOT_FOUND, + LibRnp::RNP_ERROR_NO_SUITABLE_KEY].include?(rc) + return nil + end + Rnp.raise_error('rnp_key_get_default_key failed', rc) unless rc.zero? + pkey = pptr.read_pointer + Key.new(pkey) unless pkey.null? + end + + # Export the key in the minimal form used by the Autocrypt feature + # (primary key, userid, signature, subkey, binding signature). + # + # @param subkey [Key, nil] the subkey to export (nil to pick the + # first suitable one) + # @param uid [String, nil] the userid to export (may be nil if the + # key has only one userid) + # @param base64 [Boolean] if true, the output is base64-encoded + # instead of binary OpenPGP packets + # @param output [Output] the output to write to. If nil, the result + # will be returned directly as a String. + # @return [nil, String] + def export_autocrypt(subkey: nil, uid: nil, base64: false, output: nil) + flags = base64 ? LibRnp::RNP_KEY_EXPORT_BASE64 : 0 + Output.default(output) do |output_| + Rnp.call_ffi(:rnp_key_export_autocrypt, @ptr, subkey&.ptr, uid, + output_.ptr, flags) + end + end + + # Generate and export a revocation signature for the key. + # + # @note This only exports the revocation signature. To actually + # revoke the key, import the signature into the keystore or use + # {#revoke}. + # + # @param output [Output] the output to write the signature to. If + # nil, the result will be returned directly as a String. + # @param armored [Boolean] whether to ASCII-armor the output + # @param hash (see #revoke) + # @param code (see #revoke) + # @param reason (see #revoke) + # @return [nil, String] + def export_revocation(output: nil, armored: true, hash: nil, code: nil, + reason: nil) + flags = armored ? LibRnp::RNP_KEY_EXPORT_ARMORED : 0 + Output.default(output) do |output_| + Rnp.call_ffi(:rnp_key_export_revocation, @ptr, output_.ptr, flags, + hash, code, reason) + end + end + + # Tweak the Curve25519 secret key's least and most significant bits + # so that the exported secret key is compatible with implementations + # which do not tweak these bits automatically (see RFC 7748, sec. 5). + # + # @note This is an advanced operation. It requires an unlocked + # ECDH Curve25519 secret key, so make sure to call {#lock} + # afterwards. + # + # @return [void] + def x25519_bits_tweak + Rnp.call_ffi(:rnp_key_25519_bits_tweak, @ptr) + end + + # Check whether the Curve25519 secret key's bits are correctly set + # (tweaked). If not, {#x25519_bits_tweak} should be called so that + # the exported secret key is compatible with implementations which + # do not tweak these bits automatically (see RFC 7748, sec. 5). + # + # @note This is an advanced operation. It requires an unlocked + # ECDH Curve25519 secret key. + # + # @return [Boolean] true if the secret key's bits are correctly set + def x25519_bits_tweaked? + presult = FFI::MemoryPointer.new(:bool) + Rnp.call_ffi(:rnp_key_25519_bits_tweaked, @ptr, presult) + presult.read(:bool) + end + private def string_property(func) diff --git a/spec/data/keys/key-25519-non-tweaked-sec.asc b/spec/data/keys/key-25519-non-tweaked-sec.asc new file mode 100644 index 0000000..ad4823d --- /dev/null +++ b/spec/data/keys/key-25519-non-tweaked-sec.asc @@ -0,0 +1,13 @@ +-----BEGIN PGP PRIVATE KEY BLOCK----- + +xVgEYUh6kxYJKwYBBAHaRw8BAQdAko/IzElBsrhU/2QuryqTfQIzWq/EeTwfe3Wjf7h8Go8AAP9V +pLY7sMkGlT9OporbTWHC4bxavj60oTVDLuB2CSINKA+pzRdlZGRzYS0yNTUxOS1ub24tdHdlYWtl +ZMKLBBMWCAAzFiEE3eDuU5wBfSvT9gSlMXb8FIaqJSgFAmFIepMCGwMFCwkIBwIGFQgJCgsCBRYC +AwEAAAoJEDF2/BSGqiUo5mMBAO7R4vZFJam35RYFnYPffp5rK/UprKFRheLqp3ZE6OylAP91LLnz +H4/3NlUNZr69bJHp0DJqGigYtCyaBsgUxG0LCsddBGFIepMSCisGAQQBl1UBBQEBB0BqcYeINupD +ZyMnr1zGKivq0eBswQSmdAqzdi/Skpv4ewMBCAcAAQCXobu+cYjidL6kUffBZu+jzS/GrF3sWw1k +OrvZ5Jr6rBPZwngEGBYIACAWIQTd4O5TnAF9K9P2BKUxdvwUhqolKAUCYUh6kwIbDAAKCRAxdvwU +hqolKAz1AQCMwpSgrpERHVhrQMbx9SY1KmBVWsxTXPHxqBTGlq0RmwD/fQe2EjTP10N466pmEX3a +fmruvTwBllbU+MyPQleu1g8= +=o9U3 +-----END PGP PRIVATE KEY BLOCK----- diff --git a/spec/key/lifecycle_spec.rb b/spec/key/lifecycle_spec.rb new file mode 100644 index 0000000..04a87a2 --- /dev/null +++ b/spec/key/lifecycle_spec.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +# (c) 2026 Ribose Inc. + +require 'spec_helper' + +describe 'key lifecycle' 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 + + def generate(rnp, userid, usage: nil) + op = rnp.start_generate(type: 'RSA') + op.bits = 1024 + op.userid = userid + op.usage = usage if usage + op.execute + op.key + end + + describe Rnp::Key.instance_method(:revoke) do + it 'revokes the key with a reason' do + key = generate(rnp, 'to-revoke') + key.revoke(code: 'retired', reason: 'no longer used') + expect(key.revoked?).to be true + expect(key.retired?).to be true + expect(key.compromised?).to be false + expect(key.revocation_reason).to eql 'no longer used' + end + + it 'revokes with defaults' do + key = generate(rnp, 'to-revoke-defaults') + key.revoke + expect(key.revoked?).to be true + end + end + + describe Rnp::Key.instance_method(:set_expiration) do + it 'sets the expiration time' do + key = generate(rnp, 'set-expiration') + key.set_expiration(3600) + expect(key.valid_till.to_i).to eql(key.creation_time.to_i + 3600) + end + + it 'clears the expiration time' do + key = generate(rnp, 'clear-expiration') + key.set_expiration(3600) + key.set_expiration(0) + expect(key.valid_till).to be_nil + end + end + + describe Rnp::Key.instance_method(:default_key) do + let(:key) { rnp.find_key(userid: 'key0-uid1') } + + it 'returns the encryption subkey' do + default = key.default_key(:encrypt) + expect(default).to be_a Rnp::Key + expect(default.keyid).to eql '8A05B89FAD5ADED1' + end + + it 'returns the primary key for certify' do + expect(key.default_key(:certify).keyid).to eql key.keyid + end + + it 'excludes the primary key with subkeys_only' do + default = key.default_key(:encrypt, subkeys_only: true) + expect(default.keyid).to eql '8A05B89FAD5ADED1' + expect(key.default_key(:sign, subkeys_only: true)).to be_nil + end + + it 'returns nil when no suitable key exists' do + signonly = generate(rnp, 'sign-only', usage: :sign) + expect(signonly.default_key(:encrypt)).to be_nil + end + end + + describe Rnp::Key.instance_method(:export_autocrypt) do + let(:key) { rnp.find_key(userid: 'key0-uid1') } + + it 'exports binary packets' do + exported = key.export_autocrypt(uid: 'key0-uid1') + expect(exported.encoding).to eql Encoding::BINARY + expect(Rnp.key_format(exported)).to eql 'GPG' + end + + it 'exports base64' do + exported = key.export_autocrypt(uid: 'key0-uid1', base64: true) + expect(exported).to_not include '-----BEGIN' + expect(exported).to match(/\A[A-Za-z0-9+\/=\r\n]+\z/) + end + + it 'raises an error on an ambiguous userid' do + expect { key.export_autocrypt }.to raise_error(Rnp::Error) + end + end + + describe Rnp::Key.instance_method(:export_revocation) do + it 'exports an armored revocation signature' do + key = generate(rnp, 'rev-export') + exported = key.export_revocation(code: 'superseded', + reason: 'testing export') + expect(exported.start_with?("-----BEGIN PGP PUBLIC KEY BLOCK-----\r\n")) + .to be true + end + + it 'exports a binary revocation signature' do + key = generate(rnp, 'rev-export-bin') + exported = key.export_revocation(armored: false) + expect(exported.encoding).to eql Encoding::BINARY + # a signature packet (old format, tag 2) + expect(exported.getbyte(0) & 0x3F).to eql 2 + end + + it 'can be imported to revoke the key' do + key = generate(rnp, 'rev-import') + exported = key.export_revocation(armored: false) + other = Rnp.new + other.load_keys(format: 'GPG', + input: Rnp::Input.from_string(key.export_public(armored: false))) + other.import_signatures(input: Rnp::Input.from_string(exported)) + imported = other.find_key(userid: 'rev-import') + expect(imported.revoked?).to be true + end + end + + describe 'Curve25519 bit tweaking', + skip: !LibRnp::HAVE_RNP_KEY_25519_BITS_TWEAK do + it 'reports a generated 25519 key as tweaked' do + key = rnp.generate_eddsa_25519(userid: 'tweaked-gen', password: nil) + subkey = key.subkeys[0] + expect(subkey.curve).to eql 'Curve25519' + expect(subkey.x25519_bits_tweaked?).to be true + expect { subkey.x25519_bits_tweak }.to_not raise_error + subkey.lock + end + + it 'tweaks a non-tweaked 25519 key' do + rnp.load_keys( + format: 'GPG', + input: Rnp::Input.from_path('spec/data/keys/key-25519-non-tweaked-sec.asc') + ) + subkey = rnp.find_key(keyid: '950EE0CD34613DBA') + expect(subkey.x25519_bits_tweaked?).to be false + subkey.x25519_bits_tweak + expect(subkey.x25519_bits_tweaked?).to be true + subkey.lock + end + + it 'raises an error for a non-25519 key' do + key = rnp.find_key(userid: 'key0-uid1') + expect { key.x25519_bits_tweaked? }.to raise_error(Rnp::Error) + expect { key.x25519_bits_tweak }.to raise_error(Rnp::Error) + end + end +end From c6602e8c99c066e5b4d9eb99435b032490aec32c Mon Sep 17 00:00:00 2001 From: Ronald Tse Date: Mon, 20 Jul 2026 07:30:54 +0800 Subject: [PATCH 13/14] Add op file metadata and message introspection - Sign#file_name= / #file_mtime= and Encrypt#file_name= / #file_mtime= (embedded into the literal data packet) - Encrypt#aead_bits= (AEAD chunk size bits) - Verify#protection_info (encryption mode, cipher and integrity status of the processed message) - Verify#file_info (embedded file name and modification time) --- lib/rnp/op/encrypt.rb | 30 +++++++++++++ lib/rnp/op/sign.rb | 20 +++++++++ lib/rnp/op/verify.rb | 49 ++++++++++++++++++++ spec/encrypt/encrypt_spec.rb | 30 +++++++++++++ spec/sign/sign_spec.rb | 24 ++++++++++ spec/verify/introspection_spec.rb | 74 +++++++++++++++++++++++++++++++ 6 files changed, 227 insertions(+) diff --git a/lib/rnp/op/encrypt.rb b/lib/rnp/op/encrypt.rb index 928e87c..23b5ccf 100644 --- a/lib/rnp/op/encrypt.rb +++ b/lib/rnp/op/encrypt.rb @@ -187,6 +187,36 @@ def flags=(flags) Rnp.call_ffi(:rnp_op_encrypt_set_flags, @ptr, flags) end + # Set the internally stored file name for the data being encrypted. + # + # @param file_name [String] the file name. May be an empty string. + # The special value '_CONSOLE' may have specific processing (see + # RFC 4880 for the details). + def file_name=(file_name) + Rnp.call_ffi(:rnp_op_encrypt_set_file_name, @ptr, file_name) + end + + # Set the internally stored file modification date for the data being + # encrypted. + # + # @param file_mtime [Time, Integer] the modification date. As an + # integer, this is the number of seconds since the unix epoch. + def file_mtime=(file_mtime) + file_mtime = file_mtime.to_i if file_mtime.is_a?(::Time) + Rnp.call_ffi(:rnp_op_encrypt_set_file_mtime, @ptr, file_mtime) + end + + # Set the chunk length for AEAD mode, via the number of chunk size + # bits (see the OpenPGP AEAD specification). + # + # @note This is only valid when an AEAD algorithm is used (see + # {#aead=}). + # + # @param bits [Integer] the number of bits (0..16) + def aead_bits=(bits) + Rnp.call_ffi(:rnp_op_encrypt_set_aead_bits, @ptr, bits) + end + # Execute the operation. # # This should only be called once. diff --git a/lib/rnp/op/sign.rb b/lib/rnp/op/sign.rb index 8f522d2..5c4dc79 100644 --- a/lib/rnp/op/sign.rb +++ b/lib/rnp/op/sign.rb @@ -119,6 +119,26 @@ def expiration_time=(expiration_time) Rnp.call_ffi(:rnp_op_sign_set_expiration_time, @ptr, expiration_time) end + # Set the input's file name. Makes sense only for an embedded + # signature. + # + # @param file_name [String] the source data file name. The special + # value '_CONSOLE' may be used to mark the message as 'for your + # eyes only' (see RFC 4880 for the details). + def file_name=(file_name) + Rnp.call_ffi(:rnp_op_sign_set_file_name, @ptr, file_name) + end + + # Set the input's file modification date. Makes sense only for an + # embedded signature. + # + # @param file_mtime [Time, Integer] the modification date. As an + # integer, this is the number of seconds since the unix epoch. + def file_mtime=(file_mtime) + file_mtime = file_mtime.to_i if file_mtime.is_a?(::Time) + Rnp.call_ffi(:rnp_op_sign_set_file_mtime, @ptr, file_mtime) + end + # Execute the operation. # # This should only be called once. diff --git a/lib/rnp/op/verify.rb b/lib/rnp/op/verify.rb index 30ebfea..edd9141 100644 --- a/lib/rnp/op/verify.rb +++ b/lib/rnp/op/verify.rb @@ -69,6 +69,55 @@ def format format.zero? ? nil : format.chr end + # Get information about the data protection (encryption) used in the + # processed message. + # + # @return [Hash] a hash with the following keys: + # * :mode [String] the encryption mode: 'none' (not encrypted), + # 'cfb', 'cfb-mdc', 'aead-ocb' or 'aead-eax' + # * :cipher [String] the symmetric cipher used, or nil if the + # message was not encrypted + # * :valid [Boolean] true if the message integrity protection + # (MDC or AEAD) was used and validated successfully + def protection_info + pmode = FFI::MemoryPointer.new(:pointer) + pcipher = FFI::MemoryPointer.new(:pointer) + pvalid = FFI::MemoryPointer.new(:bool) + Rnp.call_ffi(:rnp_op_verify_get_protection_info, @ptr, pmode, pcipher, + pvalid) + begin + pvalue = pmode.read_pointer + mode = pvalue.read_string unless pvalue.null? + pvalue = pcipher.read_pointer + cipher = pvalue.read_string unless pvalue.null? + { mode: mode, cipher: cipher, valid: pvalid.read(:bool) } + ensure + LibRnp.rnp_buffer_destroy(pmode.read_pointer) + LibRnp.rnp_buffer_destroy(pcipher.read_pointer) + end + end + + # Get the file name and modification time embedded in the message's + # literal data packet. Makes sense only for embedded signature + # verification. + # + # @return [Hash] a hash with the following keys: + # * :file_name [String, nil] the embedded file name, if any + # * :file_mtime [Time] the embedded modification time (the unix + # epoch if not available) + def file_info + pfilename = FFI::MemoryPointer.new(:pointer) + pmtime = FFI::MemoryPointer.new(:uint32) + Rnp.call_ffi(:rnp_op_verify_get_file_info, @ptr, pfilename, pmtime) + begin + pvalue = pfilename.read_pointer + file_name = pvalue.read_string unless pvalue.null? + { file_name: file_name, file_mtime: Time.at(pmtime.read(:uint32)) } + ensure + LibRnp.rnp_buffer_destroy(pfilename.read_pointer) + end + end + # Get a list of recipients (public keys) the message was encrypted to. # # @return [Array] diff --git a/spec/encrypt/encrypt_spec.rb b/spec/encrypt/encrypt_spec.rb index 8f81663..9653cc6 100644 --- a/spec/encrypt/encrypt_spec.rb +++ b/spec/encrypt/encrypt_spec.rb @@ -191,3 +191,33 @@ expect(verify.good?).to be true end end + +describe 'encrypt file metadata' 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 + + it 'embeds the file name and mtime in the literal data packet' do + key = rnp.find_key(userid: 'key0-uid1') + output = Rnp::Output.to_string + encrypt = rnp.start_encrypt(input: Rnp::Input.from_string('data'), + output: output) + encrypt.file_name = 'secret.bin' + encrypt.file_mtime = Time.at(1_400_000_000) + encrypt.add_recipient(key) + encrypt.execute + + decrypted = Rnp::Output.to_string + verify = rnp.start_verify(input: Rnp::Input.from_string(output.string), + output: decrypted) + verify.execute + expect(decrypted.string).to eql 'data' + info = verify.file_info + expect(info[:file_name]).to eql 'secret.bin' + expect(info[:file_mtime].to_i).to eql 1_400_000_000 + end +end diff --git a/spec/sign/sign_spec.rb b/spec/sign/sign_spec.rb index bd1a54f..5a47944 100644 --- a/spec/sign/sign_spec.rb +++ b/spec/sign/sign_spec.rb @@ -91,3 +91,27 @@ end end + +describe 'sign file metadata' do + it 'embeds the file name and mtime in the literal data packet' do + rnp = Rnp.new + rnp.load_keys(format: 'GPG', + input: Rnp::Input.from_path('spec/data/keyrings/gpg/secring.gpg')) + rnp.password_provider = 'password' + key = rnp.find_key(userid: 'key0-uid1') + output = Rnp::Output.to_string + sign = rnp.start_sign(input: Rnp::Input.from_string('data'), + output: output) + sign.file_name = 'document.txt' + sign.file_mtime = 1_450_000_000 + sign.add_signer(key) + sign.execute + + verify = rnp.start_verify(input: Rnp::Input.from_string(output.string), + output: Rnp::Output.to_null) + verify.execute + info = verify.file_info + expect(info[:file_name]).to eql 'document.txt' + expect(info[:file_mtime].to_i).to eql 1_450_000_000 + end +end diff --git a/spec/verify/introspection_spec.rb b/spec/verify/introspection_spec.rb index 27a3ccf..46e5b42 100644 --- a/spec/verify/introspection_spec.rb +++ b/spec/verify/introspection_spec.rb @@ -154,3 +154,77 @@ end end end + +describe 'message introspection' 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(:key) { rnp.find_key(userid: 'key0-uid1') } + + describe Rnp::Verify.instance_method(:protection_info) do + it 'reports no protection for a signed-only message' do + signature = rnp.sign(input: Rnp::Input.from_string('data'), + signers: [key], + armored: false) + verify = rnp.start_verify(input: Rnp::Input.from_string(signature), + output: Rnp::Output.to_null) + verify.execute + info = verify.protection_info + expect(info[:mode]).to eql 'none' + expect(info[:cipher]).to eql 'none' + expect(info[:valid]).to be false + end + + it 'reports cfb-mdc protection for an encrypted message' do + encrypted = rnp.encrypt(input: Rnp::Input.from_string('data'), + recipients: [key], + armored: false) + verify = rnp.start_verify(input: Rnp::Input.from_string(encrypted), + output: Rnp::Output.to_null) + verify.execute + info = verify.protection_info + expect(info[:mode]).to eql 'cfb-mdc' + expect(info[:cipher]).to eql 'AES256' + expect(info[:valid]).to be true + end + + it 'reports aead protection when AEAD is used' do + output = Rnp::Output.to_string + encrypt = rnp.start_encrypt(input: Rnp::Input.from_string('data'), + output: output) + encrypt.aead = 'EAX' + encrypt.aead_bits = 8 + encrypt.add_recipient(key) + encrypt.execute + verify = rnp.start_verify(input: Rnp::Input.from_string(output.string), + output: Rnp::Output.to_null) + verify.execute + info = verify.protection_info + expect(info[:mode]).to eql 'aead-eax' + expect(info[:cipher]).to eql 'AES256' + expect(info[:valid]).to be true + end + end + + describe Rnp::Verify.instance_method(:file_info) do + it 'returns the embedded file name and mtime' do + output = Rnp::Output.to_string + sign = rnp.start_sign(input: Rnp::Input.from_string('data'), + output: output) + sign.file_name = 'report.txt' + sign.file_mtime = Time.at(1_500_000_000) + sign.add_signer(key) + sign.execute + verify = rnp.start_verify(input: Rnp::Input.from_string(output.string), + output: Rnp::Output.to_null) + verify.execute + info = verify.file_info + expect(info[:file_name]).to eql 'report.txt' + expect(info[:file_mtime].to_i).to eql 1_500_000_000 + end + end +end From 6e93d7a064001066ad06c5e0c577b3d9012028e9 Mon Sep 17 00:00:00 2001 From: Ronald Tse Date: Mon, 20 Jul 2026 07:30:54 +0800 Subject: [PATCH 14/14] Add I/O stream completeness helpers - Output.to_file (with overwrite and atomic random-name options) - Output#pipe_from (copy an input to the output) - Input.from_stdin and Output.to_stdout (convenience-only wrappers, feature-gated) --- lib/rnp/input.rb | 12 ++++++++ lib/rnp/output.rb | 39 ++++++++++++++++++++++++++ spec/input_spec.rb | 30 ++++++++++++++++++++ spec/output_spec.rb | 68 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 149 insertions(+) create mode 100644 spec/input_spec.rb diff --git a/lib/rnp/input.rb b/lib/rnp/input.rb index be4371d..d774b17 100644 --- a/lib/rnp/input.rb +++ b/lib/rnp/input.rb @@ -66,6 +66,18 @@ def self.from_path(path) Input.new(pptr.read_pointer) end + # Create an Input to read from the standard input. + # + # @note This is a convenience wrapper for command-line usage. It + # requires librnp 0.16.0 or newer. + # + # @return [Input] + def self.from_stdin + pptr = FFI::MemoryPointer.new(:pointer) + Rnp.call_ffi(:rnp_input_from_stdin, pptr) + Input.new(pptr.read_pointer) + end + # Create an Input to read from an IO object. # # @param io [IO, #read] the IO object diff --git a/lib/rnp/output.rb b/lib/rnp/output.rb index b8934c1..01dc62d 100644 --- a/lib/rnp/output.rb +++ b/lib/rnp/output.rb @@ -69,6 +69,37 @@ def self.to_path(path) Output.new(pptr.read_pointer) end + # Create an Output to write to a file, with additional options. + # + # @param path [String] the path + # @param overwrite [Boolean] if true, an existing file will be + # overwritten + # @param random [Boolean] if true, the data is first written to a + # random-named temporary file, which is renamed to the target path + # when the output is finished. You may want to call {#finish} to + # make sure the final rename succeeded. + # @return [Output] + def self.to_file(path, overwrite: false, random: false) + flags = 0 + flags |= LibRnp::RNP_OUTPUT_FILE_OVERWRITE if overwrite + flags |= LibRnp::RNP_OUTPUT_FILE_RANDOM if random + pptr = FFI::MemoryPointer.new(:pointer) + Rnp.call_ffi(:rnp_output_to_file, pptr, path, flags) + Output.new(pptr.read_pointer) + end + + # Create an Output to write to the standard output. + # + # @note This is a convenience wrapper for command-line usage. It + # requires librnp 0.16.0 or newer. + # + # @return [Output] + def self.to_stdout + pptr = FFI::MemoryPointer.new(:pointer) + Rnp.call_ffi(:rnp_output_to_stdout, pptr) + Output.new(pptr.read_pointer) + end + # Create an Output to discard all writes. # # @return [Output] @@ -193,6 +224,14 @@ def finish Rnp.call_ffi(:rnp_output_finish, @ptr) end + # Read all data from the input and write it to this output. + # + # @param input [Input] the input to read from + # @return [void] + def pipe_from(input) + Rnp.call_ffi(:rnp_output_pipe, input.ptr, @ptr) + end + # @api private WRITER = lambda do |writer, _ctx, buf, buf_len| begin diff --git a/spec/input_spec.rb b/spec/input_spec.rb new file mode 100644 index 0000000..3f364ad --- /dev/null +++ b/spec/input_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# (c) 2026 Ribose Inc. + +require 'tempfile' + +require 'spec_helper' + +describe Rnp::Input do + describe Rnp::Input.method(:from_stdin), + skip: !LibRnp::HAVE_RNP_INPUT_FROM_STDIN do + it 'reads from the standard input' do + Tempfile.create('ruby-rnp-stdin') do |file| + file.write('stdin data') + file.flush + orig = $stdin.dup + begin + $stdin.reopen(file.path, 'r') + input = Rnp::Input.from_stdin + output = Rnp::Output.to_string + output.pipe_from(input) + expect(output.string).to eql 'stdin data' + ensure + $stdin.reopen(orig) + orig.close + end + end + end + end +end diff --git a/spec/output_spec.rb b/spec/output_spec.rb index d51d976..6b835cf 100644 --- a/spec/output_spec.rb +++ b/spec/output_spec.rb @@ -3,6 +3,7 @@ # (c) 2020 Ribose Inc. require 'json' +require 'tempfile' require 'spec_helper' @@ -80,3 +81,70 @@ end end end + +describe Rnp::Output.method(:to_file) do + let(:path) do + f = Tempfile.new(['ruby-rnp', '.tmp']) + path = f.path + f.close + f.unlink + path + end + + after { File.unlink(path) if File.exist?(path) } + + it 'writes to the file' do + output = Rnp::Output.to_file(path) + output.write('file contents') + output.finish + expect(File.read(path)).to eql 'file contents' + end + + it 'refuses to overwrite an existing file by default' do + File.write(path, 'existing') + expect { Rnp::Output.to_file(path) }.to raise_error(Rnp::Error) + expect(File.read(path)).to eql 'existing' + end + + it 'overwrites an existing file when requested' do + File.write(path, 'existing') + output = Rnp::Output.to_file(path, overwrite: true) + output.write('replaced') + output.finish + expect(File.read(path)).to eql 'replaced' + end + + it 'writes via a random temporary name with random: true' do + output = Rnp::Output.to_file(path, random: true) + output.write('atomic contents') + output.finish + expect(File.read(path)).to eql 'atomic contents' + end +end + +describe Rnp::Output.instance_method(:pipe_from) do + it 'copies the input to the output' do + output = Rnp::Output.to_string + output.pipe_from(Rnp::Input.from_string('piped data')) + expect(output.string).to eql 'piped data' + end +end + +describe Rnp::Output.method(:to_stdout), + skip: !LibRnp::HAVE_RNP_OUTPUT_TO_STDOUT do + it 'writes to the standard output' do + Tempfile.create('ruby-rnp-stdout') do |file| + orig = $stdout.dup + begin + $stdout.reopen(file) + output = Rnp::Output.to_stdout + output.write('stdout data') + output.finish + ensure + $stdout.reopen(orig) + orig.close + end + expect(File.read(file.path)).to eql 'stdout data' + end + end +end