fix: harden signing key handling (#1166)

* fix: harden signing key handling

* fix: validate static keys at compile time
This commit is contained in:
Dale Seo 2026-08-18 21:56:58 +09:00 • committed by GitHub
commit d5c46b3fe0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 214 additions and 43 deletions

View file

@ -52,6 +52,10 @@ fn custom_header_tool() -> Tool {
/// Signing key for SEP-2322 `requestState` sealing. A fixed key is fine for a /// Signing key for SEP-2322 `requestState` sealing. A fixed key is fine for a
/// conformance harness; real servers must load a secret out of clients' reach. /// conformance harness; real servers must load a secret out of clients' reach.
const REQUEST_STATE_KEY: &[u8] = b"rust-sdk-conformance-request-state-key!!"; const REQUEST_STATE_KEY: &[u8] = b"rust-sdk-conformance-request-state-key!!";
const _: () = assert!(
REQUEST_STATE_KEY.len() >= RequestStateCodec::MIN_KEY_LENGTH,
"REQUEST_STATE_KEY is shorter than RequestStateCodec::MIN_KEY_LENGTH",
);
#[derive(Clone)] #[derive(Clone)]
struct ConformanceServer { struct ConformanceServer {
@ -70,7 +74,7 @@ impl ConformanceServer {
subscriptions: Arc::new(Mutex::new(HashMap::new())), subscriptions: Arc::new(Mutex::new(HashMap::new())),
next_subscription: Arc::new(AtomicU64::new(0)), next_subscription: Arc::new(AtomicU64::new(0)),
log_level: Arc::new(Mutex::new(LoggingLevel::Debug)), log_level: Arc::new(Mutex::new(LoggingLevel::Debug)),
request_state_codec: RequestStateCodec::new(REQUEST_STATE_KEY), request_state_codec: RequestStateCodec::new_unchecked(REQUEST_STATE_KEY),
tasks: TaskManager::new(), tasks: TaskManager::new(),
} }
} }

View file

@ -70,6 +70,7 @@ base64 = { version = "0.23", optional = true }
# for SEP-2322 requestState integrity sealing (opt-in via the `request-state` feature) # for SEP-2322 requestState integrity sealing (opt-in via the `request-state` feature)
hmac = { version = "0.13", optional = true } hmac = { version = "0.13", optional = true }
sha2 = { version = "0.11", optional = true } sha2 = { version = "0.11", optional = true }
zeroize = { version = "1", optional = true }
# for HTTP client # for HTTP client
reqwest = { version = "0.13.2", default-features = false, features = [ reqwest = { version = "0.13.2", default-features = false, features = [
@ -128,7 +129,7 @@ macros = ["dep:rmcp-macros", "dep:pastey"]
elicitation = ["dep:url"] elicitation = ["dep:url"]
# SEP-2322 requestState integrity helper (HMAC-SHA256 seal/open codec) # SEP-2322 requestState integrity helper (HMAC-SHA256 seal/open codec)
request-state = ["dep:hmac", "dep:sha2", "base64"] request-state = ["dep:hmac", "dep:sha2", "dep:zeroize", "base64"]
# reqwest http client # reqwest http client
__reqwest = ["dep:reqwest"] __reqwest = ["dep:reqwest"]

View file

@ -40,9 +40,10 @@
//! //!
//! ``` //! ```
//! use rmcp::model::{RequestStateCodec, SealOptions}; //! use rmcp::model::{RequestStateCodec, SealOptions};
//! # fn main() -> Result<(), rmcp::model::RequestStateError> {
//! //!
//! // Derive the key from a per-process secret; keep it out of client reach. //! // Derive the key from a per-process secret; keep it out of client reach.
//! let codec = RequestStateCodec::new(b"a-32-byte-or-longer-secret-key!!!"); //! let codec = RequestStateCodec::try_new(b"a-32-byte-or-longer-secret-key!!!")?;
//! //!
//! // Bind the state to the caller and the originating request. //! // Bind the state to the caller and the originating request.
//! let context = b"user:alice|tools/call:weather"; //! let context = b"user:alice|tools/call:weather";
@ -53,11 +54,13 @@
//! //!
//! // On retry the client echoes `sealed` back untouched; the server re-derives //! // On retry the client echoes `sealed` back untouched; the server re-derives
//! // the same context and opens it. //! // the same context and opens it.
//! let opened = codec.open_with(&sealed, context).expect("integrity check passes"); //! let opened = codec.open_with(&sealed, context)?;
//! assert_eq!(opened, b"step=2"); //! assert_eq!(opened, b"step=2");
//! //!
//! // A different principal (different context) is rejected. //! // A different principal (different context) is rejected.
//! assert!(codec.open_with(&sealed, b"user:bob|tools/call:weather").is_err()); //! assert!(codec.open_with(&sealed, b"user:bob|tools/call:weather").is_err());
//! # Ok(())
//! # }
//! ``` //! ```
use std::time::Duration; use std::time::Duration;
@ -67,6 +70,7 @@ use hmac::{Hmac, KeyInit, Mac};
use serde::{Serialize, de::DeserializeOwned}; use serde::{Serialize, de::DeserializeOwned};
use sha2::Sha256; use sha2::Sha256;
use thiserror::Error; use thiserror::Error;
use zeroize::Zeroizing;
type HmacSha256 = Hmac<Sha256>; type HmacSha256 = Hmac<Sha256>;
@ -81,10 +85,17 @@ const DOMAIN: &[u8] = b"rmcp/mrtr/request-state/v1";
/// front of every sealed body. `0` means "no expiry". /// front of every sealed body. `0` means "no expiry".
const EXPIRY_LEN: usize = 8; const EXPIRY_LEN: usize = 8;
/// Errors returned when opening a sealed [`RequestStateCodec`] value. /// Errors returned when constructing a [`RequestStateCodec`] or processing a
/// sealed value.
#[derive(Debug, Error)] #[derive(Debug, Error)]
#[non_exhaustive] #[non_exhaustive]
pub enum RequestStateError { pub enum RequestStateError {
/// The signing key is shorter than [`RequestStateCodec::MIN_KEY_LENGTH`].
#[error(
"request state signing key is too short: expected at least {minimum} bytes, got {actual}"
)]
KeyTooShort { minimum: usize, actual: usize },
/// The value is not a well-formed sealed request state (wrong prefix or /// The value is not a well-formed sealed request state (wrong prefix or
/// missing sections). /// missing sections).
#[error("request state is malformed or uses an unsupported format")] #[error("request state is malformed or uses an unsupported format")]
@ -156,11 +167,12 @@ impl<'a> SealOptions<'a> {
/// [`open`](Self::open) a value, so it has to survive across the rounds of a /// [`open`](Self::open) a value, so it has to survive across the rounds of a
/// single MRTR exchange (e.g. a stable per-process or per-deployment secret). /// single MRTR exchange (e.g. a stable per-process or per-deployment secret).
/// ///
/// The key may be any length; HMAC internally normalizes it. For meaningful /// Use [`try_new`](Self::try_new) to require at least
/// security use a high-entropy key of at least 32 bytes. /// [`MIN_KEY_LENGTH`](Self::MIN_KEY_LENGTH) bytes of high-entropy key material.
/// The stored key material is zeroized when the codec is dropped.
#[derive(Clone)] #[derive(Clone)]
pub struct RequestStateCodec { pub struct RequestStateCodec {
key: Box<[u8]>, key: Zeroizing<Vec<u8>>,
} }
impl std::fmt::Debug for RequestStateCodec { impl std::fmt::Debug for RequestStateCodec {
@ -173,13 +185,46 @@ impl std::fmt::Debug for RequestStateCodec {
} }
impl RequestStateCodec { impl RequestStateCodec {
/// Creates a codec from a signing key. /// Minimum accepted signing-key length in bytes.
pub const MIN_KEY_LENGTH: usize = 32;
/// Creates a codec from a signing key without validating its length.
#[deprecated(
since = "3.1.4",
note = "use RequestStateCodec::try_new to enforce the minimum signing-key length"
)]
pub fn new(key: impl Into<Vec<u8>>) -> Self { pub fn new(key: impl Into<Vec<u8>>) -> Self {
Self::new_unchecked(key)
}
/// Creates a codec without validating the signing-key length.
///
/// Prefer [`try_new`](Self::try_new) unless the key length has already been
/// validated.
pub fn new_unchecked(key: impl Into<Vec<u8>>) -> Self {
Self { Self {
key: key.into().into_boxed_slice(), key: Zeroizing::new(key.into()),
} }
} }
/// Creates a codec from a signing key after validating its length.
///
/// # Errors
///
/// Returns [`RequestStateError::KeyTooShort`] when `key` contains fewer
/// than [`MIN_KEY_LENGTH`](Self::MIN_KEY_LENGTH) bytes.
pub fn try_new(key: impl Into<Vec<u8>>) -> Result<Self, RequestStateError> {
let key = Zeroizing::new(key.into());
if key.len() < Self::MIN_KEY_LENGTH {
return Err(RequestStateError::KeyTooShort {
minimum: Self::MIN_KEY_LENGTH,
actual: key.len(),
});
}
Ok(Self { key })
}
/// Seals raw bytes into an opaque, integrity-protected string suitable for /// Seals raw bytes into an opaque, integrity-protected string suitable for
/// use as `requestState`. /// use as `requestState`.
pub fn seal(&self, payload: &[u8]) -> String { pub fn seal(&self, payload: &[u8]) -> String {
@ -347,8 +392,8 @@ impl RequestStateCodec {
/// body. The length prefix keeps the `associated_data`/`body` boundary /// body. The length prefix keeps the `associated_data`/`body` boundary
/// unambiguous so distinct inputs cannot collide. /// unambiguous so distinct inputs cannot collide.
fn mac_for(&self, associated_data: &[u8], body: &[u8]) -> HmacSha256 { fn mac_for(&self, associated_data: &[u8], body: &[u8]) -> HmacSha256 {
let mut mac = let mut mac = HmacSha256::new_from_slice(self.key.as_slice())
HmacSha256::new_from_slice(&self.key).expect("HMAC accepts keys of any length"); .expect("HMAC accepts keys of any length");
mac.update(DOMAIN); mac.update(DOMAIN);
mac.update(&(associated_data.len() as u64).to_be_bytes()); mac.update(&(associated_data.len() as u64).to_be_bytes());
mac.update(associated_data); mac.update(associated_data);
@ -365,9 +410,42 @@ impl RequestStateCodec {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn try_new_accepts_key_at_minimum_length() {
let result = RequestStateCodec::try_new(vec![0; RequestStateCodec::MIN_KEY_LENGTH]);
assert!(result.is_ok(), "unexpected result: {result:?}");
}
#[test]
fn try_new_rejects_key_below_minimum_length() {
let actual = RequestStateCodec::MIN_KEY_LENGTH - 1;
let error = RequestStateCodec::try_new(vec![0; actual]).unwrap_err();
assert!(
matches!(
&error,
RequestStateError::KeyTooShort {
minimum: RequestStateCodec::MIN_KEY_LENGTH,
actual: error_actual,
} if *error_actual == actual
),
"unexpected error: {error}"
);
}
#[test]
#[allow(deprecated)]
fn new_accepts_short_key_for_backward_compatibility() {
let codec = RequestStateCodec::new(b"key".to_vec());
assert_eq!(codec.open(&codec.seal(b"state")).unwrap(), b"state");
}
#[test] #[test]
fn seal_open_roundtrips_bytes() { fn seal_open_roundtrips_bytes() {
let codec = RequestStateCodec::new(b"test-key-test-key-test-key-32byte".to_vec()); let codec =
RequestStateCodec::try_new(b"test-key-test-key-test-key-32byte".to_vec()).unwrap();
let sealed = codec.seal(b"hello world"); let sealed = codec.seal(b"hello world");
assert!(sealed.starts_with("rs1.")); assert!(sealed.starts_with("rs1."));
assert_eq!(codec.open(&sealed).unwrap(), b"hello world"); assert_eq!(codec.open(&sealed).unwrap(), b"hello world");
@ -380,7 +458,8 @@ mod tests {
tool: String, tool: String,
round: u32, round: u32,
} }
let codec = RequestStateCodec::new(b"another-strong-signing-key-here!!".to_vec()); let codec =
RequestStateCodec::try_new(b"another-strong-signing-key-here!!".to_vec()).unwrap();
let state = State { let state = State {
tool: "weather".into(), tool: "weather".into(),
round: 3, round: 3,
@ -392,14 +471,16 @@ mod tests {
#[test] #[test]
fn empty_payload_roundtrips() { fn empty_payload_roundtrips() {
let codec = RequestStateCodec::new(b"k".to_vec()); let codec =
RequestStateCodec::try_new(b"empty-payload-test-signing-key!!".to_vec()).unwrap();
let sealed = codec.seal(b""); let sealed = codec.seal(b"");
assert_eq!(codec.open(&sealed).unwrap(), b""); assert_eq!(codec.open(&sealed).unwrap(), b"");
} }
#[test] #[test]
fn tampered_payload_is_rejected() { fn tampered_payload_is_rejected() {
let codec = RequestStateCodec::new(b"signing-key-signing-key-signing!!".to_vec()); let codec =
RequestStateCodec::try_new(b"signing-key-signing-key-signing!!".to_vec()).unwrap();
let sealed = codec.seal(b"amount=100"); let sealed = codec.seal(b"amount=100");
// Replace the body section but keep the original tag. // Replace the body section but keep the original tag.
@ -416,8 +497,10 @@ mod tests {
#[test] #[test]
fn different_key_is_rejected() { fn different_key_is_rejected() {
let signer = RequestStateCodec::new(b"the-real-signing-key-value-here!!".to_vec()); let signer =
let attacker = RequestStateCodec::new(b"a-totally-different-forged-key!!!".to_vec()); RequestStateCodec::try_new(b"the-real-signing-key-value-here!!".to_vec()).unwrap();
let attacker =
RequestStateCodec::try_new(b"a-totally-different-forged-key!!!".to_vec()).unwrap();
let sealed = signer.seal(b"trusted"); let sealed = signer.seal(b"trusted");
assert!(matches!( assert!(matches!(
attacker.open(&sealed), attacker.open(&sealed),
@ -427,7 +510,8 @@ mod tests {
#[test] #[test]
fn appended_bytes_are_rejected() { fn appended_bytes_are_rejected() {
let codec = RequestStateCodec::new(b"key-key-key-key-key-key-key-key!!".to_vec()); let codec =
RequestStateCodec::try_new(b"key-key-key-key-key-key-key-key!!".to_vec()).unwrap();
let mut sealed = codec.seal(b"state"); let mut sealed = codec.seal(b"state");
sealed.push('x'); sealed.push('x');
assert!(codec.open(&sealed).is_err()); assert!(codec.open(&sealed).is_err());
@ -435,7 +519,8 @@ mod tests {
#[test] #[test]
fn wrong_version_prefix_is_malformed() { fn wrong_version_prefix_is_malformed() {
let codec = RequestStateCodec::new(b"key".to_vec()); let codec =
RequestStateCodec::try_new(b"wrong-version-test-signing-key!!".to_vec()).unwrap();
let sealed = codec.seal(b"state"); let sealed = codec.seal(b"state");
let bumped = sealed.replacen("rs1.", "rs2.", 1); let bumped = sealed.replacen("rs1.", "rs2.", 1);
assert!(matches!( assert!(matches!(
@ -446,7 +531,8 @@ mod tests {
#[test] #[test]
fn missing_sections_are_malformed() { fn missing_sections_are_malformed() {
let codec = RequestStateCodec::new(b"key".to_vec()); let codec =
RequestStateCodec::try_new(b"missing-sections-test-signing-key".to_vec()).unwrap();
assert!(matches!( assert!(matches!(
codec.open("rs1"), codec.open("rs1"),
Err(RequestStateError::MalformedFormat) Err(RequestStateError::MalformedFormat)
@ -463,7 +549,8 @@ mod tests {
#[test] #[test]
fn non_base64_sections_are_invalid_encoding() { fn non_base64_sections_are_invalid_encoding() {
let codec = RequestStateCodec::new(b"key".to_vec()); let codec =
RequestStateCodec::try_new(b"invalid-base64-test-signing-key!!".to_vec()).unwrap();
assert!(matches!( assert!(matches!(
codec.open("rs1.!!!!.!!!!"), codec.open("rs1.!!!!.!!!!"),
Err(RequestStateError::InvalidEncoding) Err(RequestStateError::InvalidEncoding)
@ -472,7 +559,8 @@ mod tests {
#[test] #[test]
fn debug_does_not_leak_key() { fn debug_does_not_leak_key() {
let codec = RequestStateCodec::new(b"super-secret-key".to_vec()); let codec =
RequestStateCodec::try_new(b"super-secret-key-super-secret-key!!".to_vec()).unwrap();
let rendered = format!("{codec:?}"); let rendered = format!("{codec:?}");
assert!(!rendered.contains("super-secret-key")); assert!(!rendered.contains("super-secret-key"));
assert!(rendered.contains("redacted")); assert!(rendered.contains("redacted"));
@ -483,7 +571,8 @@ mod tests {
#[test] #[test]
fn matching_context_opens() { fn matching_context_opens() {
let codec = RequestStateCodec::new(b"key-key-key-key-key-key-key-key!!".to_vec()); let codec =
RequestStateCodec::try_new(b"key-key-key-key-key-key-key-key!!".to_vec()).unwrap();
let ctx = b"user:alice|tools/call:weather"; let ctx = b"user:alice|tools/call:weather";
let sealed = codec.seal_with(b"state", &SealOptions::new().associated_data(ctx)); let sealed = codec.seal_with(b"state", &SealOptions::new().associated_data(ctx));
assert_eq!(codec.open_with(&sealed, ctx).unwrap(), b"state"); assert_eq!(codec.open_with(&sealed, ctx).unwrap(), b"state");
@ -491,7 +580,8 @@ mod tests {
#[test] #[test]
fn different_context_is_rejected() { fn different_context_is_rejected() {
let codec = RequestStateCodec::new(b"key-key-key-key-key-key-key-key!!".to_vec()); let codec =
RequestStateCodec::try_new(b"key-key-key-key-key-key-key-key!!".to_vec()).unwrap();
let sealed = let sealed =
codec.seal_with(b"state", &SealOptions::new().associated_data(b"user:alice")); codec.seal_with(b"state", &SealOptions::new().associated_data(b"user:alice"));
assert!(matches!( assert!(matches!(
@ -502,7 +592,8 @@ mod tests {
#[test] #[test]
fn missing_context_is_rejected() { fn missing_context_is_rejected() {
let codec = RequestStateCodec::new(b"key-key-key-key-key-key-key-key!!".to_vec()); let codec =
RequestStateCodec::try_new(b"key-key-key-key-key-key-key-key!!".to_vec()).unwrap();
let sealed = let sealed =
codec.seal_with(b"state", &SealOptions::new().associated_data(b"user:alice")); codec.seal_with(b"state", &SealOptions::new().associated_data(b"user:alice"));
// Opening without the associated data must fail closed. // Opening without the associated data must fail closed.
@ -520,7 +611,7 @@ mod tests {
#[test] #[test]
fn within_ttl_opens() { fn within_ttl_opens() {
let codec = RequestStateCodec::new(KEY.to_vec()); let codec = RequestStateCodec::try_new(KEY.to_vec()).unwrap();
let sealed = codec.seal_at( let sealed = codec.seal_at(
b"state", b"state",
&SealOptions::new().ttl(Duration::from_secs(60)), &SealOptions::new().ttl(Duration::from_secs(60)),
@ -532,7 +623,7 @@ mod tests {
#[test] #[test]
fn past_ttl_is_expired() { fn past_ttl_is_expired() {
let codec = RequestStateCodec::new(KEY.to_vec()); let codec = RequestStateCodec::try_new(KEY.to_vec()).unwrap();
let sealed = codec.seal_at( let sealed = codec.seal_at(
b"state", b"state",
&SealOptions::new().ttl(Duration::from_secs(60)), &SealOptions::new().ttl(Duration::from_secs(60)),
@ -547,14 +638,14 @@ mod tests {
#[test] #[test]
fn no_ttl_never_expires() { fn no_ttl_never_expires() {
let codec = RequestStateCodec::new(KEY.to_vec()); let codec = RequestStateCodec::try_new(KEY.to_vec()).unwrap();
let sealed = codec.seal_at(b"state", &SealOptions::new(), 1_000); let sealed = codec.seal_at(b"state", &SealOptions::new(), 1_000);
assert_eq!(codec.open_at(&sealed, &[], i64::MAX).unwrap(), b"state"); assert_eq!(codec.open_at(&sealed, &[], i64::MAX).unwrap(), b"state");
} }
#[test] #[test]
fn ttl_and_associated_data_combine() { fn ttl_and_associated_data_combine() {
let codec = RequestStateCodec::new(KEY.to_vec()); let codec = RequestStateCodec::try_new(KEY.to_vec()).unwrap();
let ctx = b"user:alice"; let ctx = b"user:alice";
let sealed = codec.seal_at( let sealed = codec.seal_at(
b"state", b"state",

View file

@ -914,7 +914,7 @@ impl JwtSigningAlgorithm {
/// This supports two authentication methods: /// This supports two authentication methods:
/// - `ClientSecret`: credentials sent in the request body /// - `ClientSecret`: credentials sent in the request body
/// - `PrivateKeyJwt`: RFC 7523 signed JWT assertion (requires `auth-client-credentials-jwt` feature) /// - `PrivateKeyJwt`: RFC 7523 signed JWT assertion (requires `auth-client-credentials-jwt` feature)
#[derive(Debug, Clone)] #[derive(Clone)]
#[non_exhaustive] #[non_exhaustive]
pub enum ClientCredentialsConfig { pub enum ClientCredentialsConfig {
/// Client secret authentication (credentials in request body) /// Client secret authentication (credentials in request body)
@ -937,6 +937,42 @@ pub enum ClientCredentialsConfig {
}, },
} }
impl std::fmt::Debug for ClientCredentialsConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ClientSecret {
client_id,
scopes,
resource,
..
} => f
.debug_struct("ClientSecret")
.field("client_id", client_id)
.field("client_secret", &"<redacted>")
.field("scopes", scopes)
.field("resource", resource)
.finish(),
#[cfg(feature = "auth-client-credentials-jwt")]
Self::PrivateKeyJwt {
client_id,
signing_algorithm,
token_endpoint_audience,
scopes,
resource,
..
} => f
.debug_struct("PrivateKeyJwt")
.field("client_id", client_id)
.field("signing_key", &"<redacted>")
.field("signing_algorithm", signing_algorithm)
.field("token_endpoint_audience", token_endpoint_audience)
.field("scopes", scopes)
.field("resource", resource)
.finish(),
}
}
}
#[cfg(feature = "auth-client-credentials-jwt")] #[cfg(feature = "auth-client-credentials-jwt")]
fn client_authentication_audience<'a>( fn client_authentication_audience<'a>(
metadata: &'a AuthorizationMetadata, metadata: &'a AuthorizationMetadata,
@ -7501,6 +7537,40 @@ mod tests {
assert!(matches!(oauth_client.auth_type(), AuthType::RequestBody)); assert!(matches!(oauth_client.auth_type(), AuthType::RequestBody));
} }
#[test]
fn client_secret_credentials_debug_redacts_secret() {
let config = super::ClientCredentialsConfig::ClientSecret {
client_id: "client-id".to_string(),
client_secret: "client-secret-value".to_string(),
scopes: vec![],
resource: None,
};
let rendered = format!("{config:?}");
assert!(!rendered.contains("client-secret-value"), "{rendered}");
}
#[cfg(feature = "auth-client-credentials-jwt")]
#[test]
fn private_key_jwt_credentials_debug_redacts_signing_key() {
let config = super::ClientCredentialsConfig::PrivateKeyJwt {
client_id: "client-id".to_string(),
signing_key: b"private-signing-key-value".to_vec(),
signing_algorithm: super::JwtSigningAlgorithm::RS256,
token_endpoint_audience: None,
scopes: vec![],
resource: None,
};
let rendered = format!("{config:?}");
assert!(
!rendered.contains("private-signing-key-value"),
"{rendered}"
);
}
#[tokio::test] #[tokio::test]
async fn configure_client_credentials_sets_correct_client_id() { async fn configure_client_credentials_sets_correct_client_id() {
let mut mgr = manager_with_metadata(None).await; let mut mgr = manager_with_metadata(None).await;

View file

@ -594,11 +594,15 @@ async fn request_state_codec_seals_and_verifies_through_the_loop() -> anyhow::Re
use rmcp::model::RequestStateCodec; use rmcp::model::RequestStateCodec;
// A shared per-process signing key, mirroring how a real server would derive one. // A shared per-process signing key, mirroring how a real server would derive one.
static KEY: &[u8] = b"integration-signing-key-32-bytes!"; const KEY: &[u8] = b"integration-signing-key-32-bytes!";
const _: () = assert!(
KEY.len() >= RequestStateCodec::MIN_KEY_LENGTH,
"KEY is shorter than RequestStateCodec::MIN_KEY_LENGTH",
);
fn codec() -> &'static RequestStateCodec { fn codec() -> &'static RequestStateCodec {
static CODEC: OnceLock<RequestStateCodec> = OnceLock::new(); static CODEC: OnceLock<RequestStateCodec> = OnceLock::new();
CODEC.get_or_init(|| RequestStateCodec::new(KEY)) CODEC.get_or_init(|| RequestStateCodec::new_unchecked(KEY))
} }
#[derive(Clone, Default)] #[derive(Clone, Default)]

View file

@ -41,8 +41,8 @@ use rmcp::{
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
/// A stable, high-entropy secret. In a real deployment, load this from your /// A fixed key for this example. Real deployments should load at least 32 bytes
/// secret manager and keep it out of clients' reach. It must stay constant for /// of random secret key material from a secret manager and keep it stable for
/// the lifetime of any in-flight MRTR exchange. /// the lifetime of any in-flight MRTR exchange.
const REQUEST_STATE_KEY: &[u8] = b"example-request-state-signing-key-32b!"; const REQUEST_STATE_KEY: &[u8] = b"example-request-state-signing-key-32b!";
@ -63,11 +63,11 @@ struct WeatherServer {
codec: RequestStateCodec, codec: RequestStateCodec,
} }
impl Default for WeatherServer { impl WeatherServer {
fn default() -> Self { fn new(key: impl Into<Vec<u8>>) -> Result<Self, RequestStateError> {
Self { Ok(Self {
codec: RequestStateCodec::new(REQUEST_STATE_KEY), codec: RequestStateCodec::try_new(key)?,
} })
} }
} }
@ -197,10 +197,11 @@ impl ClientHandler for InteractiveClient {
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
let (server_transport, client_transport) = tokio::io::duplex(8192); let (server_transport, client_transport) = tokio::io::duplex(8192);
let server = WeatherServer::new(REQUEST_STATE_KEY)?;
// Spin up the server side. // Spin up the server side.
tokio::spawn(async move { tokio::spawn(async move {
let server = WeatherServer::default() let server = server
.serve(server_transport) .serve(server_transport)
.await .await
.expect("server should start"); .expect("server should start");
@ -208,7 +209,7 @@ async fn main() -> anyhow::Result<()> {
}); });
// Connect the client (this performs the initialize handshake). // Connect the client (this performs the initialize handshake).
let client = InteractiveClient::default().serve(client_transport).await?; let client = InteractiveClient.serve(client_transport).await?;
// 1. High-level auto mode: the SDK fulfils the elicitation and retries for us. // 1. High-level auto mode: the SDK fulfils the elicitation and retries for us.
println!("== auto mode (call_tool) =="); println!("== auto mode (call_tool) ==");
@ -316,7 +317,7 @@ mod tests {
#[test] #[test]
fn open_request_state_rejects_unknown_phase() { fn open_request_state_rejects_unknown_phase() {
let server = WeatherServer::default(); let server = WeatherServer::new(REQUEST_STATE_KEY).unwrap();
let sealed = server let sealed = server
.codec .codec
.seal_json(&json!({ "awaiting": "country" })) .seal_json(&json!({ "awaiting": "country" }))