Skip to content

Add ExportPublicKey API for cached asymmetric keys#346

Open
Frauschi wants to merge 3 commits intowolfSSL:mainfrom
Frauschi:export_pub_key
Open

Add ExportPublicKey API for cached asymmetric keys#346
Frauschi wants to merge 3 commits intowolfSSL:mainfrom
Frauschi:export_pub_key

Conversation

@Frauschi
Copy link
Copy Markdown
Contributor

Add ExportPublicKey API for cached asymmetric key objects

Summary

Adds a dedicated path for extracting only the public half of a cached public-key keypair, so callers that need a public key on the client side (signature verification, certificate building, key transport, etc.) no longer have to pull the private material out of the HSM.

Previously, the only way to get the public half of a cached keypair was to call wh_Client_<Algo>ExportKey(), which goes through the algorithm-agnostic wh_Client_KeyExport() and returns the raw cached DER — including any private material. For the common case "I cached a keypair for on-HSM signing and I just need the public key on the client," shipping the private key defeats the security benefit of caching.

This PR adds:

  • Non-DMA path: new keystore action WH_KEY_EXPORT_PUBLIC + per-algorithm client wrappers.
  • DMA path: parallel WH_KEY_EXPORT_PUBLIC_DMA + wh_Client_KeyExportPublicDma generic transport + per-algorithm DMA wrapper for ML-DSA (matches the existing ML-DSA-only DMA-export precedent).

Algorithms wired end-to-end

Algorithm Non-DMA client wrapper DMA
RSA wh_Client_RsaExportPublicKey via generic transport
ECC wh_Client_EccExportPublicKey via generic transport
Ed25519 wh_Client_Ed25519ExportPublicKey via generic transport
Curve25519 wh_Client_Curve25519ExportPublicKey via generic transport
ML-DSA (Dilithium) wh_Client_MlDsaExportPublicKey wh_Client_MlDsaExportPublicKeyDma

Design notes

  • Single keystore action with an algo selector. Cached keys are stored as opaque DER — NVM metadata does not record an algorithm type — so the server needs the algorithm to know how to decode. Using one wire action keeps the translate/lock/label plumbing shared with WH_KEY_EXPORT instead of duplicating it per algorithm. The selector is a new WH_KEY_ALGO_* enum in wolfhsm/wh_common.h.
  • NONEXPORTABLE carve-out. WH_NVM_FLAGS_NONEXPORTABLE blocks full-export but not public-only export, because public material is non-sensitive and blocking it would make cached keys unusable for any external verification or key-transport use case. This is a dedicated WH_KS_OP_EXPORT_PUBLIC branch in _KeystoreCheckPolicy (not a silent bypass) and is called out explicitly in the docs.
  • Reuse. The server handler deserializes directly from cacheBuf/cacheMeta using the existing wh_Crypto_*DeserializeKeyDer helpers (which already fall back to public-only decode), then re-emits public-only DER via the matching wc_*PublicKeyToDer. No new server-side crypto helpers introduced.
  • DMA staging with zero extra server stack. The DMA handler stages the public DER in the unused tail of resp_packet (the DMA response struct itself only occupies the header), then whServerDma_CopyToClients it into the client-provided buffer. The response sent over the wire is just sizeof(resp).
  • Error handling. Wrong algo selector → ASN parse error bubbles up. Unknown keyId → WH_ERROR_NOTFOUND. wc_*PublicKeyToDer returning 0 → WH_ERROR_ABORTED (explicitly, not a silent zero-length success). Too-small DMA client buffer → WH_ERROR_NOSPACE.

Wire protocol

New keystore actions (appended to enum WH_KEY_ENUM so existing numeric values are preserved):

  • WH_KEY_EXPORT_PUBLIC
  • WH_KEY_EXPORT_PUBLIC_DMA (under WOLFHSM_CFG_DMA)

Each takes a uint16_t algo selector alongside the standard keyId. Integrators with custom transports route these the same way they route WH_KEY_EXPORT / WH_KEY_EXPORT_DMA.

Test plan

End-to-end per algorithm:

  • RSANONEXPORTABLE cached key → full export denied with WH_ERROR_ACCESSwh_Client_RsaExportPublicKey succeeds → client-side wc_RsaPublicEncrypt / HSM-side wc_RsaPrivateDecrypt round-trips plaintext. Includes wrong-algo and unknown-keyId (WH_ERROR_NOTFOUND) negative cases.
  • ECC — HSM signs with cached private, client verifies locally with exported public; asserts type == ECC_PUBLICKEY.
  • Ed25519 — HSM signs via wh_Client_Ed25519Sign, client verifies with wc_ed25519_verify_msg; asserts pubKeySet==1 && privKeySet==0.
  • Curve25519 — X25519 shared-secret round-trip: local_priv·hsm_pub == hsm_priv·local_pub.
  • ML-DSA — MakeCacheKey (level 2) → wh_Client_MlDsaExportPublicKey → asserts pubKeySet==1 && prvKeySet==0.

DMA-specific coverage:

  • ECC DMA — HSM sign (non-DMA cryptoCb) + client verify, where the public half is pulled via wh_Client_KeyExportPublicDma (generic transport).
  • ML-DSA DMAwh_Client_MlDsaExportPublicKeyDma + flag assertions, plus a byte-identity check comparing DMA-path DER vs. non-DMA-path DER for the same cached key, plus a WH_ERROR_NOSPACE negative test with an undersized client buffer.

Docs updated in docs/src/chapter05.md and docs/src-ja/chapter05.md.

@Frauschi Frauschi self-assigned this Apr 24, 2026
@Frauschi
Copy link
Copy Markdown
Contributor Author

Currently, this work is orthogonal to #336, so this PR is missing ML-KEM support and #336 is missing support for this new API. Depending on what goes into main first, the other one needs some updates.

Main goal for now is to get some feedback on the general API and design.

Copy link
Copy Markdown
Contributor

@bigbrett bigbrett left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall, looks great. A few nits and suggetsions, with some architectural things as well.

One thing I'm not sure about is the bypass of the NONEXPORTABLE attribute. I totally see why you would do it this way, but I don't want to prevent a customer from making an object fully nonexportable (e.g. unreadable) for whatever reason. Therefore I wonder if it makes sense to have additional attributes here that only apply to keys? Happy to brainstorm this.

Comment thread wolfhsm/wh_client.h Outdated
* @return int Returns 0 on success, or a negative error code on failure.
*/
int wh_Client_KeyExportPublicDmaRequest(whClientContext* c, whKeyId keyId,
uint16_t algo, const void* keyAddr,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure how I feel about this being const. While technically correct, as this function doesn't actually modify the data pointed to, it tells the server to modify it, so the const contract is quite disingenuous.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, you're right on this. I removed the const qualifier.

Comment thread wolfhsm/wh_common.h
/* Public-key algorithm selector used by APIs that must interpret a cached
* key's DER contents (e.g. WH_KEY_EXPORT_PUBLIC), since NVM metadata does
* not carry an algorithm type. Values are on-wire, append-only. */
enum WH_KEY_ALGO_ENUM {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmmm wish we could use enum wc_PkType from wolfssl/wolfcrypt/types.h here but if we are to differentiate between PQC subtypes then unfortunately we can't. No change necessary

Copy link
Copy Markdown
Contributor Author

@Frauschi Frauschi Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm also not a fan of the design decision to wrap all PQC algorithms behind the two generic WC_PK_TYPE_PQC_KEM_ and WC_PK_TYPE_PQC_SIG_ types in enum wc_PkType. But changing this now would be a big break for all CryptoCb users, so thats what we have to live with...

Comment thread src/wh_client.c Outdated
Comment on lines +1666 to +1667
if ((resp_group != WH_MESSAGE_GROUP_KEY) ||
(resp_action != WH_KEY_EXPORT_PUBLIC_DMA) ||
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dont need to do this anymore, since it is checked in wh_Client_RecvResponse() against the last sent kind (which is the fused group/action

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed

Comment thread src/wh_client.c Outdated
Comment on lines +977 to +982
if (labelSz > sizeof(resp->label)) {
memcpy(label, resp->label, WH_NVM_LABEL_LEN);
}
else {
memcpy(label, resp->label, labelSz);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: condense to single memcpy with labelSz clamped if it exceeds WH_NVM_LABEL_LEN. See how you did it elsewhere in this file.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

Comment thread src/wh_client_crypto.c
Comment on lines +3381 to +3386
if ((label_len > 0) && (label != NULL)) {
if (label_len > WH_NVM_LABEL_LEN) {
label_len = WH_NVM_LABEL_LEN;
}
memcpy(label, keyLabel, label_len);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are we redundantly re-clamping the label here? Isn't this already done in wh_Client_KeyExportPublic()?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The clamping here is currently necessary as we initially read the exported label in the local keyLabel buffer with its own size (not necessarily the same size as the user provided buffer). wh_Client_KeyExportPublic()clamps for the internal buffer only.

We could directly export the label in the user provided label buffer via wh_Client_KeyExportPublic() to prevent both the additional buffer and the second clamping, but that would have the side-effect of also writing into that buffer even when the key deserialization fails afterward.

I haven't done any changes yet, but could do the above if desired.

Comment thread src/wh_server_keystore.c
Comment on lines +111 to +112
/* Exporting only the public half of a public-key object is considered
* non-sensitive and is intentionally not gated by NONEXPORTABLE. */
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmmm I don't know if I agree with this - NONEXPORTABLE is a generic object-level attribute that doesn't care if the object is a key. I wonder if we need a new attribute/flag. Currently we have "sensitive" which means that it can never be exported in unwrapped form so perhaps it makes sense to have a new flag for public and private halves of a key? PUBNONEXPORTABLE and PRIVNONEXPORTABLE? Let me think on this ....

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Haven't done any changes for this.

This is definitely a broader design decision worth discussing. We can add two new flags PUBNONEXPORTABLE and PRIVNONEXPORTABLE, but how would these interplay with the existing NONEXPORTABLE?

Currently, I cannot see any realistic use case in which one would prevent exporting the public key, but having the option for it is valid. So the public key of an asymmetric key would be exportable always, except for when PUBNONEXPORTABLE is set on key generation or import.

The private part would work similarly (prevent export only when PRIVNONEXPORTABLE is set on the object). The question is now how to handle the generic NONEXPORTABLE? I see these options now:

  1. NONEXPORTABLE applies to both public and private parts, so both PUBNONEXPORTABLE and PRIVNONEXPORTABLE are set on the key.
  2. NONEXPORTABLE applies only to the private part, so only PRIVNONEXPORTABLE is set on the key. The public part could still be exported, as it is non-sensitive.

As I currently lack a realistic use case for public key protection, I'm in favor for option 2. That maps to the typical use cases of asymmetric key generation for authentication (send the public key to a peer for signature verification) as well as key agreement (send the public key to a peer for ECDH/KEM encapsulation).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is indeed a handy simplification to have it work the way you designed it. I'm just wondering if there is ever a case where you woulndt want to export pubkey. They are all contrived but it is a slight change to the object model. Ill think on it, probs fine for now

Comment thread src/wh_server_keystore.c Outdated
Comment on lines +1892 to +1912
RsaKey rsa[1];
int pub_ret;
ret = wc_InitRsaKey_ex(rsa, NULL, INVALID_DEVID);
if (ret == 0) {
ret = wh_Server_CacheExportRsaKey(
server, serverKeyId, rsa);
if (ret == 0) {
pub_ret = wc_RsaKeyToPublicDer(
rsa, stage, (word32)stageMax);
if (pub_ret > 0) {
der_len = (uint16_t)pub_ret;
}
else {
ret = (pub_ret == 0)
? WH_ERROR_ABORTED
: pub_ret;
}
}
wc_FreeRsaKey(rsa);
}
} break;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you unify the wolfCrypt public extraction and epxort logic for each algo into helper functions to keep the switch statement smaller? Seems you should be able to share this across DMA/non-DMA versions to prevent duplication too.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed as suggested.

Introduces a new keystore action WH_KEY_EXPORT_PUBLIC that re-emits only
the public portion of a cached public-key object, so callers that need a
public key for a client-side operation (signature verification, key
transport, etc.) no longer have to pull private material out of the HSM.
A new WH_KS_OP_EXPORT_PUBLIC policy branch gates the path and
intentionally bypasses NONEXPORTABLE since public material is
non-sensitive.

Wired end-to-end for RSA, ECC, Ed25519, Curve25519, and ML-DSA, with
per-algorithm client wrappers (wh_Client_<Algo>ExportPublicKey) and
smoke tests that round-trip real operations (sign/verify, ECDH) against
the exported public keys, plus a negative test for unknown keyId.

Also adds a DMA variant (WH_KEY_EXPORT_PUBLIC_DMA) with a generic client
transport and an ML-DSA-specific wrapper, byte-identity cross-validation
against the non-DMA path, and a NOSPACE bounds-check test.

Documentation added to docs/src/chapter05.md and docs/src-ja/chapter05.md.
New message structs registered in the padding-check test.
@Frauschi
Copy link
Copy Markdown
Contributor Author

Addressed the review comments (except for two which are open for discussion).

I also had to increase WOLFHSM_CFG_SERVER_KEYCACHE_BIG_BUFSIZE from 4096 to 5120 to incorporate the maximum ML-DSA key size. This issue was never triggered previously, as wh_Client_MlDsaMakeCacheKey() has never been called in any test before.

@bigbrett bigbrett assigned rizlik and bigbrett and unassigned Frauschi Apr 29, 2026
@bigbrett bigbrett requested a review from rizlik April 29, 2026 16:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants