diff --git a/lib/rnp.rb b/lib/rnp.rb index 3d5f297..4668c4e 100644 --- a/lib/rnp.rb +++ b/lib/rnp.rb @@ -10,6 +10,8 @@ require 'rnp/op/verify' 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/ffi/librnp.rb b/lib/rnp/ffi/librnp.rb index 85a2356..2ad8146 100644 --- a/lib/rnp/ffi/librnp.rb +++ b/lib/rnp/ffi/librnp.rb @@ -127,6 +127,123 @@ 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], + # 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 @@ -218,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 @@ -296,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 @@ -332,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 @@ -377,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 @@ -410,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 @@ -442,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) @@ -461,10 +647,60 @@ 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 + RNP_ERROR_KEY_NOT_FOUND = 0x12000005 RNP_ERROR_NO_SUITABLE_KEY = 0x12000006 RNP_ERROR_SIGNATURE_EXPIRED = 0x1200000B end # module 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/key.rb b/lib/rnp/key.rb index 375b87f..40daa32 100644 --- a/lib/rnp/key.rb +++ b/lib/rnp/key.rb @@ -433,6 +433,344 @@ 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 + # 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 + + # 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. + # + # @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 + + # 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) @@ -494,7 +832,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/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/lib/rnp/misc.rb b/lib/rnp/misc.rb index 7762fa4..9d4494c 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 @@ -288,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/lib/rnp/op/encrypt.rb b/lib/rnp/op/encrypt.rb index 641f275..23b5ccf 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 @@ -173,6 +177,46 @@ 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 + + # 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/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/op/sign.rb b/lib/rnp/op/sign.rb index 57d09cb..5c4dc79 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 @@ -115,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 3c5fd30..edd9141 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 @@ -41,6 +45,129 @@ 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 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] + 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 +275,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/output.rb b/lib/rnp/output.rb index d690bcb..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] @@ -86,6 +117,66 @@ 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) + 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. # # @param strings [String] @@ -111,6 +202,36 @@ 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 + + # 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/lib/rnp/rnp.rb b/lib/rnp/rnp.rb index efce98b..c3c8d4e 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. # @@ -620,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 @@ -694,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/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/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/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/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/encrypt/encrypt_spec.rb b/spec/encrypt/encrypt_spec.rb index 3be867b..9653cc6 100644 --- a/spec/encrypt/encrypt_spec.rb +++ b/spec/encrypt/encrypt_spec.rb @@ -144,3 +144,80 @@ 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 + +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/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/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/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 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 diff --git a/spec/key_signature_spec.rb b/spec/key_signature_spec.rb new file mode 100644 index 0000000..7510238 --- /dev/null +++ b/spec/key_signature_spec.rb @@ -0,0 +1,162 @@ +# 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 + + # 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 + 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) 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 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..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' @@ -24,3 +25,126 @@ 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 + + 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 + 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 + +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 diff --git a/spec/rnp_spec.rb b/spec/rnp_spec.rb index 9dcfa3a..bdde33f 100644 --- a/spec/rnp_spec.rb +++ b/spec/rnp_spec.rb @@ -427,3 +427,73 @@ 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 + +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 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 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/signature_spec.rb b/spec/signature_spec.rb index 62a46c6..422f0b9 100644 --- a/spec/signature_spec.rb +++ b/spec/signature_spec.rb @@ -78,3 +78,211 @@ 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', + skip: !Rnp.has?('signature-validity-status') 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', + skip: !Rnp.has?('signature-validity-status') 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/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 diff --git a/spec/verify/introspection_spec.rb b/spec/verify/introspection_spec.rb new file mode 100644 index 0000000..46e5b42 --- /dev/null +++ b/spec/verify/introspection_spec.rb @@ -0,0 +1,230 @@ +# 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 + +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