Skip to content

Add an async Stamp trait to support KMS/HSM-backed stampers #130

Description

@chris13524

This issue was drafted by an AI assistant and reviewed by me before posting.

Motivation

We want to stamp Turnkey API requests using a private key stored in AWS KMS rather than loaded into our process from a parameter store, so the P-256 signing key never leaves the HSM boundary. The same need would apply to other cloud KMS / HSM backends (GCP KMS, Azure Key Vault, YubiHSM, etc.).

KMS-backed signing is inherently async (aws_sdk_kms::Client::sign(...).send().await), but the current Stamp trait is sync:

pub trait Stamp {
    fn stamp(&self, body: &[u8]) -> Result<StampHeader, StamperError>;
}

This forces consumers into one of two unpleasant workarounds:

  1. Bridge sync→async with block_in_place + Handle::block_on (what we're doing today). This works only inside a multi-threaded tokio runtime, ties up a worker thread per signature, and isn't friendly to non-tokio runtimes:

    impl Stamp for KmsP256ApiKey {
        fn stamp(&self, body: &[u8]) -> Result<StampHeader, StamperError> {
            let handle = Handle::try_current().map_err(|e| {
                StamperError::InvalidPrivateKeyBytes(format!(
                    "KMS stamper requires a multi-threaded tokio runtime: {e}"
                ))
            })?;
            let der_signature = tokio::task::block_in_place(|| {
                handle.block_on(self.sign_async(body))
            })
            .map_err(|e| StamperError::InvalidPrivateKeyBytes(e.to_string()))?;
            // ... build StampHeader
        }
    }
  2. Bypass TurnkeyClient and reimplement sign_raw_payload — write our own POST to /public/v1/submit/sign_raw_payload, plus the activity polling loop in process_activity. This gets us out of the sync/async mismatch but duplicates the logic the SDK already provides (retries, timeouts, status polling, error mapping).

Neither is great. Both will be unnecessary if Stamp had an async variant.

Proposal

Add an AsyncStamp trait alongside Stamp, with a blanket impl so every existing Stamp automatically satisfies AsyncStamp. Then change TurnkeyClient's type-parameter bound from S: Stamp to S: AsyncStamp. Because of the blanket impl, no existing user code has to change — TurnkeyClient<TurnkeyP256ApiKey> still compiles, because TurnkeyP256ApiKey: Stamp implies TurnkeyP256ApiKey: AsyncStamp.

Changes to turnkey_api_key_stamper

Add (existing Stamp trait stays untouched):

#[async_trait::async_trait]
pub trait AsyncStamp: Send + Sync {
    async fn stamp(&self, body: &[u8]) -> Result<StampHeader, StamperError>;
}

// Any existing sync stamper is automatically an AsyncStamp:
#[async_trait::async_trait]
impl<S: Stamp + Send + Sync> AsyncStamp for S {
    async fn stamp(&self, body: &[u8]) -> Result<StampHeader, StamperError> {
        Stamp::stamp(self, body)
    }
}

This is sound under the orphan rules because Stamp and AsyncStamp are both defined in the same crate as the blanket impl.

Changes to turnkey_client

The Stamp bound becomes AsyncStamp, and the call site in process_request becomes .await:

-pub struct TurnkeyClientBuilder<S: Stamp> { ... }
-pub struct TurnkeyClient<S: Stamp> { ... }
-impl<S: Stamp> TurnkeyClient<S> { ... }
+pub struct TurnkeyClientBuilder<S: AsyncStamp> { ... }
+pub struct TurnkeyClient<S: AsyncStamp> { ... }
+impl<S: AsyncStamp> TurnkeyClient<S> { ... }

 // inside process_request:
-let StampHeader { name, value } = self.api_key.stamp(post_body.as_bytes())?;
+let StampHeader { name, value } = self.api_key.stamp(post_body.as_bytes()).await?;

That's the whole change on the SDK side — one trait bound and one .await.

What changes for users

Use case Before After
Sync stamper (TurnkeyP256ApiKey, TurnkeySecp256k1ApiKey) impl Stamp unchanged — gets AsyncStamp via blanket impl
Cloud-KMS / HSM stamper not possible without block_in_place hack or bypassing the client impl AsyncStamp directly, no sync/async bridge
TurnkeyClient<MyStamper> construction unchanged unchanged

So this is fully backwards-compatible: existing dependents recompile without changes.

Alternative: make Stamp itself async

If you'd rather have a single trait, Stamp::stamp could become async directly. Cleaner long-term but a breaking change requiring a major version bump of both crates. The two-trait approach above avoids that.

What I can contribute

Happy to open a PR with the additive AsyncStamp approach (including updates to TurnkeyClient and tests) if that direction sounds good. Just want to check the design first before writing the patch.

Versions

  • turnkey_client = "0.3"
  • turnkey_api_key_stamper = "0.3"

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions