diff --git a/include/rnp/rnp.h b/include/rnp/rnp.h index 033610b9c..d2d7930d8 100644 --- a/include/rnp/rnp.h +++ b/include/rnp/rnp.h @@ -104,6 +104,9 @@ typedef uint32_t rnp_result_t; * Flags for default key selection. */ #define RNP_KEY_SUBKEYS_ONLY (1U << 0) +#if defined(RNP_EXPERIMENTAL_PQC) +#define RNP_KEY_PREFER_PQC_ENC_SUBKEY (1U << 1) +#endif /** * User id type @@ -2349,6 +2352,9 @@ RNP_API rnp_result_t rnp_key_get_subkey_at(rnp_key_handle_t key, * @param flags possible values: RNP_KEY_SUBKEYS_ONLY - select only subkeys, * otherwise if flags is 0, primary key can be returned if * it is suitable for specified usage. + * Note: If RNP_EXPERIMENTAL_PQC is set, then the flag + * RNP_KEY_PREFER_PQC_ENC_SUBKEY can be used to prefer PQC-encryption subkeys + * over non-PQC-encryption subkeys * @param default_key on success resulting key handle will be stored here, otherwise it * will contain NULL value. You must free this handle after use with * rnp_key_handle_destroy(). @@ -3615,6 +3621,18 @@ RNP_API rnp_result_t rnp_op_encrypt_add_recipient(rnp_op_encrypt_t op, rnp_key_h RNP_API rnp_result_t rnp_op_encrypt_enable_pkesk_v6(rnp_op_encrypt_t op); #endif +#if defined(RNP_EXPERIMENTAL_PQC) +/** + * @brief Prefer using PQC subkeys over non-PQC subkeys when encrypting. + * NOTE: This is an experimental feature and this function can be replaced (or removed) + * at any time. + * + * @param op opaque encrypting context. Must be allocated and initialized. + * @return RNP_SUCCESS or errorcode if failed. + */ +RNP_API rnp_result_t rnp_op_encrypt_prefer_pqc_enc_subkey(rnp_op_encrypt_t op); +#endif + /** * @brief Add signature to encrypting context, so data will be encrypted and signed. * diff --git a/src/lib/pgp-key.cpp b/src/lib/pgp-key.cpp index 3897f2693..601c72a99 100644 --- a/src/lib/pgp-key.cpp +++ b/src/lib/pgp-key.cpp @@ -435,7 +435,11 @@ pgp_subkey_set_expiration(pgp_key_t * sub, } pgp_key_t * -find_suitable_key(pgp_op_t op, pgp_key_t *key, rnp::KeyProvider *key_provider, bool no_primary) +find_suitable_key(pgp_op_t op, + pgp_key_t * key, + rnp::KeyProvider *key_provider, + bool no_primary, + bool pref_pqc_sub) { if (!key || !key_provider) { return NULL; @@ -472,6 +476,21 @@ find_suitable_key(pgp_op_t op, pgp_key_t *key, rnp::KeyProvider *key_provider, b if (!cur || !cur->usable_for(op)) { continue; } +#if defined(ENABLE_PQC) + if (pref_pqc_sub && op == PGP_OP_ENCRYPT) { + /* prefer PQC encryption over non-PQC encryption. Assume non-PQC key is only there + * for backwards compatibility. */ + if (subkey && subkey->is_pqc_alg() && !cur->is_pqc_alg()) { + /* do not override already found PQC key with non-PQC key */ + continue; + } + if (subkey && cur->is_pqc_alg() && !subkey->is_pqc_alg()) { + /* override non-PQC key with PQC key */ + subkey = cur; + continue; + } + } +#endif if (!subkey || (cur->creation() > subkey->creation())) { subkey = cur; } @@ -1301,6 +1320,41 @@ pgp_key_t::has_secret() const noexcept } } +#if defined(ENABLE_PQC) +bool +pgp_key_t::is_pqc_alg() const +{ + switch (alg()) { + case PGP_PKA_KYBER768_X25519: + FALLTHROUGH_STATEMENT; + case PGP_PKA_KYBER768_P256: + FALLTHROUGH_STATEMENT; + case PGP_PKA_KYBER1024_P384: + FALLTHROUGH_STATEMENT; + case PGP_PKA_KYBER768_BP256: + FALLTHROUGH_STATEMENT; + case PGP_PKA_KYBER1024_BP384: + FALLTHROUGH_STATEMENT; + case PGP_PKA_DILITHIUM3_ED25519: + FALLTHROUGH_STATEMENT; + case PGP_PKA_DILITHIUM3_P256: + FALLTHROUGH_STATEMENT; + case PGP_PKA_DILITHIUM5_P384: + FALLTHROUGH_STATEMENT; + case PGP_PKA_DILITHIUM3_BP256: + FALLTHROUGH_STATEMENT; + case PGP_PKA_DILITHIUM5_BP384: + FALLTHROUGH_STATEMENT; + case PGP_PKA_SPHINCSPLUS_SHA2: + FALLTHROUGH_STATEMENT; + case PGP_PKA_SPHINCSPLUS_SHAKE: + return true; + default: + return false; + } +} +#endif + bool pgp_key_t::usable_for(pgp_op_t op, bool if_secret) const { diff --git a/src/lib/pgp-key.h b/src/lib/pgp-key.h index 5bac8acaf..5ff6de8d3 100644 --- a/src/lib/pgp-key.h +++ b/src/lib/pgp-key.h @@ -229,6 +229,9 @@ struct pgp_key_t { bool can_certify() const noexcept; bool can_encrypt() const noexcept; bool has_secret() const noexcept; +#if defined(ENABLE_PQC) + bool is_pqc_alg() const; +#endif /** * @brief Check whether key is usable for the specified operation. * @@ -688,6 +691,7 @@ bool pgp_subkey_set_expiration(pgp_key_t * sub, pgp_key_t *find_suitable_key(pgp_op_t op, pgp_key_t * key, rnp::KeyProvider *key_provider, - bool no_primary = false); + bool no_primary = false, + bool pref_pqc_sub = false); #endif // RNP_PACKET_KEY_H diff --git a/src/lib/rnp.cpp b/src/lib/rnp.cpp index ebc9c6cda..73f538657 100644 --- a/src/lib/rnp.cpp +++ b/src/lib/rnp.cpp @@ -2594,8 +2594,16 @@ try { return RNP_ERROR_NULL_POINTER; } - pgp_key_t *key = find_suitable_key( - PGP_OP_ENCRYPT, get_key_prefer_public(handle), &handle->ffi->key_provider); +#if defined(ENABLE_PQC) + bool prefer_pqc = op->rnpctx.pref_pqc_enc_subkey; +#else + bool prefer_pqc = false; +#endif + pgp_key_t *key = find_suitable_key(PGP_OP_ENCRYPT, + get_key_prefer_public(handle), + &handle->ffi->key_provider, + false, + prefer_pqc); if (!key) { return RNP_ERROR_NO_SUITABLE_KEY; } @@ -2618,6 +2626,20 @@ try { FFI_GUARD #endif +#if defined(RNP_EXPERIMENTAL_PQC) +rnp_result_t +rnp_op_encrypt_prefer_pqc_enc_subkey(rnp_op_encrypt_t op) +try { + if (!op) { + return RNP_ERROR_NULL_POINTER; + } + + op->rnpctx.pref_pqc_enc_subkey = true; + return RNP_SUCCESS; +} +FFI_GUARD +#endif + rnp_result_t rnp_op_encrypt_add_signature(rnp_op_encrypt_t op, rnp_key_handle_t key, @@ -7296,6 +7318,11 @@ try { return RNP_ERROR_BAD_PARAMETERS; } bool no_primary = extract_flag(flags, RNP_KEY_SUBKEYS_ONLY); +#if defined(ENABLE_PQC) + bool prefer_pqc_enc_subkey = extract_flag(flags, RNP_KEY_PREFER_PQC_ENC_SUBKEY); +#else + bool prefer_pqc_enc_subkey = false; +#endif if (flags) { FFI_LOG(primary_key->ffi, "Invalid flags: %" PRIu32, flags); return RNP_ERROR_BAD_PARAMETERS; @@ -7321,8 +7348,8 @@ try { if (!key) { return RNP_ERROR_BAD_PARAMETERS; } - pgp_key_t *defkey = - find_suitable_key(op, key, &primary_key->ffi->key_provider, no_primary); + pgp_key_t *defkey = find_suitable_key( + op, key, &primary_key->ffi->key_provider, no_primary, prefer_pqc_enc_subkey); if (!defkey) { *default_key = NULL; return RNP_ERROR_NO_SUITABLE_KEY; diff --git a/src/librepgp/stream-ctx.h b/src/librepgp/stream-ctx.h index 12639bc4e..fa140c313 100644 --- a/src/librepgp/stream-ctx.h +++ b/src/librepgp/stream-ctx.h @@ -70,8 +70,10 @@ typedef struct rnp_symmetric_pass_info_t { * - halg : hash algorithm used during key derivation for password-based encryption * - ealg, aalg, abits : symmetric encryption algorithm and AEAD parameters if used * - recipients : list of key ids used to encrypt data to - * - enable_pkesk_v6 : if true and each recipient in the list of recipients has the - * capability, allows PKESKv5/SEIPDv2 + * - enable_pkesk_v6 (Only if defined: ENABLE_CRYPTO_REFRESH): if true and each recipient in + * the list of recipients has the capability, allows PKESKv6/SEIPDv2 + * - pref_pqc_enc_subkey (Only if defined: ENABLE_PQC): if true, prefers PQC subkey over + * non-PQC subkey for encryption. * - passwords : list of passwords used for password-based encryption * - filename, filemtime, zalg, zlevel : see previous * - pkeskv6_capable() : returns true if all keys support PKESKv6+SEIPDv2, false otherwise @@ -108,6 +110,9 @@ typedef struct rnp_ctx_t { bool no_wrap{}; /* do not wrap source in literal data packet */ #if defined(ENABLE_CRYPTO_REFRESH) bool enable_pkesk_v6{}; /* allows pkesk v6 if list of recipients is suitable */ +#endif +#if defined(ENABLE_PQC) + bool pref_pqc_enc_subkey{}; /* prefer to encrypt to PQC subkey */ #endif std::list recipients{}; /* recipients of the encrypted message */ std::list passwords{}; /* passwords to encrypt message */ diff --git a/src/librepgp/stream-packet.cpp b/src/librepgp/stream-packet.cpp index b5983f935..1ed223984 100644 --- a/src/librepgp/stream-packet.cpp +++ b/src/librepgp/stream-packet.cpp @@ -1373,14 +1373,17 @@ pgp_pk_sesskey_t::write_material(const pgp_encrypted_material_t &material) FALLTHROUGH_STATEMENT; case PGP_PKA_KYBER768_BP256: FALLTHROUGH_STATEMENT; - case PGP_PKA_KYBER1024_BP384: + case PGP_PKA_KYBER1024_BP384: { pktbody.add(material.kyber_ecdh.composite_ciphertext); - pktbody.add_byte(static_cast(material.kyber_ecdh.wrapped_sesskey.size()) + 1); + uint8_t opt_salg_length = (version == PGP_PKSK_V3) ? 1 : 0; + pktbody.add_byte(static_cast(material.kyber_ecdh.wrapped_sesskey.size()) + + opt_salg_length); if (version == PGP_PKSK_V3) { pktbody.add_byte(salg); /* added as plaintext */ } pktbody.add(material.kyber_ecdh.wrapped_sesskey); break; + } #endif default: RNP_LOG("Unknown pk alg: %d", (int) alg); diff --git a/src/tests/cli_tests.py b/src/tests/cli_tests.py index f21514158..0bf79eadc 100755 --- a/src/tests/cli_tests.py +++ b/src/tests/cli_tests.py @@ -328,6 +328,17 @@ def rnp_genkey_rsa(userid, bits=2048, pswd=PASSWORD): if ret != 0: raise_err('rsa key generation failed', err) +def rnp_genkey_pqc(userid, algo_cli_nr, homedir, algo_param = None, pswd=PASSWORD): + algo_pipe = str(algo_cli_nr) + if algo_param: + algo_pipe += "\n" + str(algo_param) + ret, out, err = run_proc(RNPK, ['--homedir', homedir, '--password', pswd, + '--notty', '--userid', userid, '--generate-key', '--expert'], algo_pipe) + #os.close(algo_pipe) + if ret != 0: + raise_err('pqc key generation failed', err) + return out + def rnp_params_insert_z(params, pos, z): if z: if len(z) > 0 and z[0] != None: @@ -372,8 +383,10 @@ def rnp_encrypt_file_ex(src, dst, recipients=None, passwords=None, aead=None, ci raise_err('rnp encryption failed with ' + cipher, err) def rnp_encrypt_and_sign_file(src, dst, recipients, encrpswd, signers, signpswd, - aead=None, cipher=None, z=None, armor=False): - params = ['--homedir', RNPDIR, '--sign', '--encrypt', src, '--output', dst] + aead=None, cipher=None, z=None, armor=False, homedir=None): + if not homedir: + homedir = RNPDIR + params = ['--homedir', homedir, '--sign', '--encrypt', src, '--output', dst] pipe = pswd_pipe('\n'.join(encrpswd + signpswd)) params[2:2] = ['--pass-fd', str(pipe)] @@ -398,10 +411,12 @@ def rnp_encrypt_and_sign_file(src, dst, recipients, encrpswd, signers, signpswd, if ret != 0: raise_err('rnp encrypt-and-sign failed', err) -def rnp_decrypt_file(src, dst, password = PASSWORD): +def rnp_decrypt_file(src, dst, password = PASSWORD, homedir = None): + if not homedir: + homedir = RNPDIR pipe = pswd_pipe(password) ret, out, err = run_proc( - RNP, ['--homedir', RNPDIR, '--pass-fd', str(pipe), '--decrypt', src, '--output', dst]) + RNP, ['--homedir', homedir, '--pass-fd', str(pipe), '--decrypt', src, '--output', dst]) os.close(pipe) if ret != 0: raise_err('rnp decryption failed', out + err) @@ -865,7 +880,7 @@ def gpg_check_features(): print('GPG_BRAINPOOL: ' + str(GPG_BRAINPOOL)) def rnp_check_features(): - global RNP_TWOFISH, RNP_BRAINPOOL, RNP_AEAD, RNP_AEAD_EAX, RNP_AEAD_OCB, RNP_AEAD_OCB_AES, RNP_IDEA, RNP_BLOWFISH, RNP_CAST5, RNP_RIPEMD160 + global RNP_TWOFISH, RNP_BRAINPOOL, RNP_AEAD, RNP_AEAD_EAX, RNP_AEAD_OCB, RNP_AEAD_OCB_AES, RNP_IDEA, RNP_BLOWFISH, RNP_CAST5, RNP_RIPEMD160, RNP_PQC global RNP_BOTAN_OCB_AV ret, out, _ = run_proc(RNP, ['--version']) if ret != 0: @@ -892,6 +907,9 @@ def rnp_check_features(): RNP_BLOWFISH = re.match(r'(?s)^.*Encryption:.*BLOWFISH.*', out) is not None RNP_CAST5 = re.match(r'(?s)^.*Encryption:.*CAST5.*', out) is not None RNP_RIPEMD160 = re.match(r'(?s)^.*Hash:.*RIPEMD160.*', out) is not None + # Determine PQC support in general. If present, assume that all PQC schemes are supported. + pqc_strs = ['ML-KEM', 'ML-DSA'] + RNP_PQC = any([re.match('(?s)^.*Public key:.*' + scheme + '.*', out) is not None for scheme in pqc_strs]) print('RNP_TWOFISH: ' + str(RNP_TWOFISH)) print('RNP_BLOWFISH: ' + str(RNP_BLOWFISH)) print('RNP_IDEA: ' + str(RNP_IDEA)) @@ -902,6 +920,7 @@ def rnp_check_features(): print('RNP_AEAD_OCB: ' + str(RNP_AEAD_OCB)) print('RNP_AEAD_OCB_AES: ' + str(RNP_AEAD_OCB_AES)) print('RNP_BOTAN_OCB_AV: ' + str(RNP_BOTAN_OCB_AV)) + print('RNP_PQC: ' + str(RNP_PQC)) def setup(loglvl): # Setting up directories. @@ -4521,7 +4540,7 @@ def test_encryption_multiple_recipients(self): gpg_decrypt_file(dst, dec, pswd) gpg_agent_clear_cache() remove_files(dec) - rnp_decrypt_file(dst, dec, '\n'.join([pswd] * 5)) + rnp_decrypt_file(dst, dec, password='\n'.join([pswd] * 5)) remove_files(dec) # Decrypt file with each of the passwords (with gpg only first password is checked) @@ -4535,7 +4554,7 @@ def test_encryption_multiple_recipients(self): gpg_decrypt_file(dst, dec, pswd) gpg_agent_clear_cache() remove_files(dec) - rnp_decrypt_file(dst, dec, '\n'.join([pswd] * 5)) + rnp_decrypt_file(dst, dec, password='\n'.join([pswd] * 5)) remove_files(dec) remove_files(dst, dec) @@ -4585,7 +4604,7 @@ def test_encryption_and_signing(self): gpg_decrypt_file(dst, dec, pswd) gpg_agent_clear_cache() remove_files(dec) - rnp_decrypt_file(dst, dec, '\n'.join([pswd] * 5)) + rnp_decrypt_file(dst, dec, password='\n'.join([pswd] * 5)) remove_files(dec) # GPG decrypts only with first password, see T3795 @@ -4600,11 +4619,84 @@ def test_encryption_and_signing(self): gpg_decrypt_file(dst, dec, pswd) gpg_agent_clear_cache() remove_files(dec) - rnp_decrypt_file(dst, dec, '\n'.join([pswd] * 5)) + rnp_decrypt_file(dst, dec, password='\n'.join([pswd] * 5)) remove_files(dec) remove_files(dst, dec) + + def verify_pqc_algo_ui_nb_to_algo_ui_str(self, stdout: str, algo_ui_exp_strs) -> None: + stdout_lines = stdout.split('\n') + for expected_line in algo_ui_exp_strs: + found_this_entry : bool = False + for line in stdout_lines: + # compare ignore whitespaces and tabs: + re_patt_for_algo = r'[^\t ]' + char_list_expected = [c for c in expected_line if re.match(re_patt_for_algo, c)] + char_list_actual = [c for c in line if re.match(re_patt_for_algo, c)] + if char_list_expected == char_list_actual: + found_this_entry = True + break + + if not found_this_entry: + raise RuntimeError("did not match the expected UI choice for algorithm: " + expected_line) + + def test_encryption_and_signing_pqc(self): + if not RNP_PQC: + return + RNPDIR_PQC = RNPDIR + 'PQC' + os.mkdir(RNPDIR_PQC, 0o700) + algo_ui_exp_strs = [ "(24) Ed25519Legacy + Curve25519Legacy + (ML-KEM-768 + X25519)", + "(25) (ML-DSA-65 + Ed25519) + (ML-KEM-768 + X25519)", + "(27) (ML-DSA-65 + ECDSA-NIST-P-256) + (ML-KEM-768 + ECDH-NIST-P-256)", + "(28) (ML-DSA-87 + ECDSA-NIST-P-384) + (ML-KEM-1024 + ECDH-NIST-P-384)", + "(29) (ML-DSA-65 + ECDSA-brainpoolP256r1) + (ML-KEM-768 + ECDH-brainpoolP256r1)", + "(30) (ML-DSA-87 + ECDSA-brainpoolP384r1) + (ML-KEM-1024 + ECDH-brainpoolP384r1)", + "(31) SLH-DSA-SHA2 + MLKEM-ECDH Composite", + "(32) SLH-DSA-SHAKE + MLKEM-ECDH Composite", + ] + USERIDS = ['enc-sign25@rnp', 'enc-sign27@rnp', 'enc-sign28@rnp', 'enc-sign29@rnp', 'enc-sign30@rnp','enc-sign32a@rnp','enc-sign32b@rnp','enc-sign32c@rnp','enc-sign24-v4-key@rnp'] + + # '24' in the below array creates a v4 primary signature key with a v4 pqc subkey without a Features Subpacket. This way we test PQC encryption to a v4 subkey. RNP prefers the PQC subkey in case of a certificate having a PQC and a + # non-PQC subkey. + ALGO = [25, 27, 28, 29, 30, 32, 32, 32, 24, ] + ALGO_PARAM = [None, None, None, None, None, 1, 2, 6, None, ] + aead_list = [] + passwds = [ ] + for x in range(len(ALGO)): passwds.append('testpw' if x % 1 == 0 else '') + for x in range(len(ALGO)): aead_list.append(None if x % 3 == 0 else ('ocb' if x % 3 == 1 else 'eax' )) + if any(len(USERIDS) != len(x) for x in [ALGO, ALGO_PARAM]): + raise RuntimeError("test_encryption_and_signing_pqc: internal error: lengths of test data arrays matching") + # Generate multiple keys and import to GnuPG + verified_algo_nums = False + for uid, algo, param, passwd in zip(USERIDS, ALGO, ALGO_PARAM, passwds): + stdout = rnp_genkey_pqc(uid, algo, RNPDIR_PQC, param, passwd) + if not verified_algo_nums: + self.verify_pqc_algo_ui_nb_to_algo_ui_str(stdout, algo_ui_exp_strs) + verified_algo_nums = True + + src, dst, dec = reg_workfiles('cleartext', '.txt', '.rnp', '.dec') + # Generate random file of required size + random_text(src, 65500) + + for i in range(0, len(USERIDS)): + signers = [USERIDS[i]] + #signpswd = KEYPASS[:SIGNERS[i]] + #keynum, pswdnum = KEYPSWD[i] + recipients = [USERIDS[i]] + passwords = [] # SKESK for v6 not yet supported + signerpws = [passwds[i]] + + rnp_encrypt_and_sign_file(src, dst, recipients, passwords, signers, + signerpws, aead=[aead_list[i]], homedir=RNPDIR_PQC) + # Decrypt file with each of the keys, we have different password for each key + rnp_decrypt_file(dst, dec, password=passwds[i], homedir=RNPDIR_PQC) + remove_files(dst, dec) + + clear_workfiles() + shutil.rmtree(RNPDIR_PQC, ignore_errors=True) + + def test_encryption_weird_userids_special_1(self): uid = WEIRD_USERID_SPECIAL_CHARS pswd = 'encSpecial1Pass' @@ -4614,7 +4706,7 @@ def test_encryption_weird_userids_special_1(self): dst, dec = reg_workfiles('weird_userids_special_1', '.rnp', '.dec') rnp_encrypt_file_ex(src, dst, [uid], None, None) # Decrypt - rnp_decrypt_file(dst, dec, pswd) + rnp_decrypt_file(dst, dec, password=pswd) compare_files(src, dec, RNP_DATA_DIFFERS) clear_workfiles() @@ -4631,7 +4723,7 @@ def test_encryption_weird_userids_special_2(self): # Decrypt file with each of the passwords for pswd in KEYPASS: multiple_pass_attempts = (pswd + '\n') * len(KEYPASS) - rnp_decrypt_file(dst, dec, multiple_pass_attempts) + rnp_decrypt_file(dst, dec, password=multiple_pass_attempts) compare_files(src, dec, RNP_DATA_DIFFERS) remove_files(dec) # Cleanup @@ -4657,7 +4749,7 @@ def test_encryption_weird_userids_unicode(self): # Decrypt file with each of the passwords for pswd in KEYPASS: multiple_pass_attempts = (pswd + '\n') * len(KEYPASS) - rnp_decrypt_file(dst, dec, multiple_pass_attempts) + rnp_decrypt_file(dst, dec, password=multiple_pass_attempts) compare_files(src, dec, RNP_DATA_DIFFERS) remove_files(dec) # Cleanup @@ -4719,7 +4811,7 @@ def test_encryption_x25519(self): ret, out, _ = run_proc(RNP, ['--homedir', RNPDIR, '-es', '-r', 'eddsa_25519', '-u', 'eddsa_25519', '--password', PASSWORD, src, '--output', dst, '--armor']) # Decrypt and verify with RNP - rnp_decrypt_file(dst, dec, 'password') + rnp_decrypt_file(dst, dec, password='password') self.assertEqual(file_text(src), file_text(dec)) remove_files(dec) # Decrypt and verify with GPG @@ -4731,7 +4823,7 @@ def test_encryption_x25519(self): '-u', 'eddsa_25519', '--output', dst, '-es', src]) self.assertEqual(ret, 0) # Decrypt and verify with RNP - rnp_decrypt_file(dst, dec, 'password') + rnp_decrypt_file(dst, dec, password='password') self.assertEqual(file_text(src), file_text(dec)) # Encrypt/decrypt using the p256 key, making sure message is not displayed key = data_path('test_stream_key_load/ecc-p256-sec.asc') diff --git a/src/tests/ffi-enc.cpp b/src/tests/ffi-enc.cpp index c7308dbef..73709dd35 100644 --- a/src/tests/ffi-enc.cpp +++ b/src/tests/ffi-enc.cpp @@ -912,6 +912,71 @@ TEST_F(rnp_tests, test_ffi_decrypt_pqc_pkesk_test_vector) rnp_ffi_destroy(ffi); } + +TEST_F(rnp_tests, test_ffi_pqc_default_enc_subkey) +{ + rnp_ffi_t ffi = NULL; + rnp_key_handle_t key1 = NULL; + rnp_key_handle_t key2 = NULL; + rnp_key_handle_t defkey1 = NULL; + rnp_key_handle_t defkey2 = NULL; + rnp_op_generate_t op = NULL; + assert_rnp_success(rnp_ffi_create(&ffi, "GPG", "GPG")); + + /* generate key 1 */ + assert_rnp_success(rnp_op_generate_create(&op, ffi, "ML-DSA-65+ED25519")); + assert_rnp_success(rnp_op_generate_set_hash(op, "SHA3-256")); + assert_rnp_success(rnp_op_generate_execute(op)); + + assert_rnp_success(rnp_op_generate_get_key(op, &key1)); + assert_non_null(key1); + assert_rnp_success(rnp_op_generate_destroy(op)); + op = NULL; + assert_rnp_success(rnp_op_generate_subkey_create(&op, ffi, key1, "ML-KEM-768+X25519")); + assert_rnp_success(rnp_op_generate_execute(op)); + rnp_op_generate_destroy(op); + op = NULL; + assert_rnp_success(rnp_op_generate_subkey_create(&op, ffi, key1, "ECDH")); + assert_rnp_success(rnp_op_generate_set_curve(op, "NIST P-256")); + assert_rnp_success(rnp_op_generate_execute(op)); + rnp_op_generate_destroy(op); + op = NULL; + + /* generate key 2 */ + assert_rnp_success(rnp_op_generate_create(&op, ffi, "ML-DSA-65+ED25519")); + assert_rnp_success(rnp_op_generate_set_hash(op, "SHA3-256")); + assert_rnp_success(rnp_op_generate_execute(op)); + assert_rnp_success(rnp_op_generate_get_key(op, &key2)); + assert_non_null(key2); + assert_rnp_success(rnp_op_generate_destroy(op)); + op = NULL; + assert_rnp_success(rnp_op_generate_subkey_create(&op, ffi, key2, "ECDH")); + assert_rnp_success(rnp_op_generate_set_curve(op, "NIST P-256")); + assert_rnp_success(rnp_op_generate_execute(op)); + rnp_op_generate_destroy(op); + op = NULL; + assert_rnp_success(rnp_op_generate_subkey_create(&op, ffi, key2, "ML-KEM-768+X25519")); + assert_rnp_success(rnp_op_generate_execute(op)); + rnp_op_generate_destroy(op); + op = NULL; + + /* check default key */ + assert_rnp_success( + rnp_key_get_default_key(key1, "encrypt", RNP_KEY_PREFER_PQC_ENC_SUBKEY, &defkey1)); + // PQC key is older but preferred + assert(defkey1->pub->alg() == PGP_PKA_KYBER768_X25519); + assert_rnp_success( + rnp_key_get_default_key(key2, "encrypt", RNP_KEY_PREFER_PQC_ENC_SUBKEY, &defkey2)); + // PQC key is newer and preferred + assert(defkey2->pub->alg() == PGP_PKA_KYBER768_X25519); + + /* cleanup */ + rnp_key_handle_destroy(key1); + rnp_key_handle_destroy(key2); + rnp_key_handle_destroy(defkey1); + rnp_key_handle_destroy(defkey2); + rnp_ffi_destroy(ffi); +} #endif TEST_F(rnp_tests, test_ffi_encrypt_pk_with_v6_key)