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:
-
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
}
}
-
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"
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 currentStamptrait is sync:This forces consumers into one of two unpleasant workarounds:
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:Bypass
TurnkeyClientand reimplementsign_raw_payload— write our own POST to/public/v1/submit/sign_raw_payload, plus the activity polling loop inprocess_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
Stamphad an async variant.Proposal
Add an
AsyncStamptrait alongsideStamp, with a blanket impl so every existingStampautomatically satisfiesAsyncStamp. Then changeTurnkeyClient's type-parameter bound fromS: StamptoS: AsyncStamp. Because of the blanket impl, no existing user code has to change —TurnkeyClient<TurnkeyP256ApiKey>still compiles, becauseTurnkeyP256ApiKey: StampimpliesTurnkeyP256ApiKey: AsyncStamp.Changes to
turnkey_api_key_stamperAdd (existing
Stamptrait stays untouched):This is sound under the orphan rules because
StampandAsyncStampare both defined in the same crate as the blanket impl.Changes to
turnkey_clientThe
Stampbound becomesAsyncStamp, and the call site inprocess_requestbecomes.await:That's the whole change on the SDK side — one trait bound and one
.await.What changes for users
TurnkeyP256ApiKey,TurnkeySecp256k1ApiKey)impl StampAsyncStampvia blanket implblock_in_placehack or bypassing the clientimpl AsyncStampdirectly, no sync/async bridgeTurnkeyClient<MyStamper>constructionSo this is fully backwards-compatible: existing dependents recompile without changes.
Alternative: make
Stampitself asyncIf you'd rather have a single trait,
Stamp::stampcould 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
AsyncStampapproach (including updates toTurnkeyClientand 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"