use std::time::{Duration, SystemTime};
use microvms_app::agents::bedrock::{BearerToken, MAX_LIFETIME, Minted};
use microvms_app::error::{Error, ErrorKind};
use microvms_app::region::Region;
const HOST: &str = "bedrock.amazonaws.com";
const SERVICE: &str = "bedrock";
const ACTION: &str = "CallWithBearerToken";
const PREFIX: &str = "bedrock-api-key-";
const VERSION_SUFFIX: &str = "&Version=1";
pub async fn mint(region: &Region, lifetime: Duration) -> Result<Minted, Error> {
let provider = crate::control::transport::default_credentials(region).await?;
let credentials =
crate::control::transport::resolve_credentials(&provider, "Bedrock token mint").await?;
mint_with(&credentials, region, lifetime, SystemTime::now())
}
pub fn mint_with_credentials(
region: &Region,
access_key_id: &str,
secret_access_key: &str,
session_token: Option<&str>,
credentials_expires_at: SystemTime,
lifetime: Duration,
) -> Result<Minted, Error> {
if access_key_id.is_empty() || secret_access_key.is_empty() {
return Err(Error::invalid_arg(
"explicit AWS signing credentials must not be empty",
));
}
let credentials = aws_credential_types::Credentials::new(
access_key_id,
secret_access_key,
session_token.map(str::to_string),
Some(credentials_expires_at),
"explicit-bedrock-signing-session",
);
mint_with(&credentials, region, lifetime, SystemTime::now())
}
pub fn mint_with(
credentials: &aws_credential_types::Credentials,
region: &Region,
lifetime: Duration,
now: SystemTime,
) -> Result<Minted, Error> {
use aws_sigv4::http_request::{
SignableBody, SignableRequest, SignatureLocation, SigningSettings, sign,
};
use aws_sigv4::sign::v4;
use base64::Engine as _;
if lifetime.is_zero() || lifetime > MAX_LIFETIME {
return Err(Error::invalid_arg(format!(
"a Bedrock bearer token lives between 1 and {} seconds ({} hours); {} seconds \
was asked for. The ceiling is the token generator's `TOKEN_DURATION`, and the \
service additionally caps validity at the signing credentials' own expiry.",
MAX_LIFETIME.as_secs(),
MAX_LIFETIME.as_secs() / 3600,
lifetime.as_secs(),
)));
}
let expires_at = credentials
.expiry()
.map_or(now + lifetime, |expiry| expiry.min(now + lifetime));
if expires_at <= now {
return Err(Error::invalid_arg(
"AWS signing credentials have expired; obtain a fresh session",
));
}
let mut settings = SigningSettings::default();
settings.signature_location = SignatureLocation::QueryParams;
settings.expires_in = Some(lifetime);
let identity = credentials.clone().into();
let params: aws_sigv4::http_request::SigningParams = v4::SigningParams::builder()
.identity(&identity)
.region(region.as_str())
.name(SERVICE)
.time(now)
.settings(settings)
.build()
.map_err(|error| {
Error::new(
ErrorKind::Unexpected,
format!("could not build SigV4 presign parameters: {error}"),
)
.with_source(error)
})?
.into();
let url = format!("https://{HOST}/?Action={ACTION}");
let mut request = http::Request::builder()
.method("POST")
.uri(&url)
.header("host", HOST)
.body(Vec::<u8>::new())
.map_err(|error| {
Error::new(
ErrorKind::Unexpected,
format!("could not build the presign request: {error}"),
)
.with_source(error)
})?;
let instructions = {
let signable = SignableRequest::new(
"POST",
&url,
[("host", HOST)].into_iter(),
SignableBody::Bytes(&[]),
)
.map_err(|error| {
Error::new(
ErrorKind::Unexpected,
format!("could not build the signable presign request: {error}"),
)
.with_source(error)
})?;
sign(signable, ¶ms)
.map_err(|error| {
Error::new(
ErrorKind::Unexpected,
format!("SigV4 presigning failed: {error}"),
)
.with_source(error)
})?
.into_parts()
.0
};
instructions.apply_to_request_http1x(&mut request);
let presigned = request.uri().to_string();
let stripped = presigned.strip_prefix("https://").ok_or_else(|| {
Error::new(
ErrorKind::Unexpected,
format!("the presigned URL did not start with https://: {presigned}"),
)
})?;
let payload = format!("{stripped}{VERSION_SUFFIX}");
let token = format!(
"{PREFIX}{}",
base64::engine::general_purpose::STANDARD.encode(payload.as_bytes())
);
Ok(Minted {
token: BearerToken::new(token),
expires_at,
})
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine as _;
fn credentials() -> aws_credential_types::Credentials {
aws_credential_types::Credentials::new(
"AKIDEXAMPLE",
"wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
Some("SESSIONTOKENEXAMPLE".to_string()),
None,
"test",
)
}
fn decode(token: &BearerToken) -> String {
let encoded = token
.expose()
.strip_prefix(PREFIX)
.expect("the reference prefix");
String::from_utf8(
base64::engine::general_purpose::STANDARD
.decode(encoded)
.expect("standard base64"),
)
.expect("utf-8")
}
#[test]
fn the_token_decodes_to_the_reference_presigned_url() {
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_789_000_000);
let minted = mint_with(
&credentials(),
&Region::UsEast1,
Duration::from_secs(3600),
now,
)
.expect("mints");
let url = decode(&minted.token);
assert!(
url.starts_with("bedrock.amazonaws.com/?"),
"the host is fixed and the scheme is stripped: {url}"
);
assert!(url.contains("Action=CallWithBearerToken"), "{url}");
assert!(url.contains("X-Amz-Algorithm=AWS4-HMAC-SHA256"), "{url}");
assert!(url.contains("X-Amz-Expires=3600"), "{url}");
assert!(url.contains("X-Amz-SignedHeaders=host"), "{url}");
assert!(
url.contains("X-Amz-Security-Token=SESSIONTOKENEXAMPLE"),
"{url}"
);
assert!(
url.contains("%2Fus-east-1%2Fbedrock%2Faws4_request"),
"the credential scope names the region and the bedrock service: {url}"
);
assert!(url.contains("X-Amz-Signature="), "{url}");
assert!(
url.ends_with("&Version=1"),
"the version suffix is appended after signing, so it is last: {url}"
);
assert_eq!(minted.expires_at, now + Duration::from_secs(3600));
}
#[test]
fn a_lifetime_outside_the_reference_range_is_refused() {
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_789_000_000);
for lifetime in [Duration::ZERO, MAX_LIFETIME + Duration::from_secs(1)] {
let error =
mint_with(&credentials(), &Region::UsEast1, lifetime, now).expect_err("refused");
assert_eq!(error.kind(), ErrorKind::InvalidArg);
assert!(error.to_string().contains("43200"), "{error}");
}
mint_with(&credentials(), &Region::UsEast1, MAX_LIFETIME, now)
.expect("the ceiling itself is legal");
}
#[test]
fn minting_is_deterministic_in_its_inputs_and_sensitive_to_the_region() {
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_789_000_000);
let a = mint_with(&credentials(), &Region::UsEast1, MAX_LIFETIME, now).expect("a");
let b = mint_with(&credentials(), &Region::UsEast1, MAX_LIFETIME, now).expect("b");
let c = mint_with(&credentials(), &Region::EuWest1, MAX_LIFETIME, now).expect("c");
assert_eq!(a.token, b.token);
assert_ne!(a.token, c.token);
}
#[test]
fn explicit_credential_expiry_caps_the_token_and_expired_sessions_are_refused() {
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_789_000_000);
let expiry = now + Duration::from_secs(600);
let credentials = aws_credential_types::Credentials::new(
"synthetic-id",
"synthetic-secret",
Some("synthetic-session".into()),
Some(expiry),
"test",
);
let minted = mint_with(&credentials, &Region::UsEast1, MAX_LIFETIME, now).expect("mint");
assert_eq!(minted.expires_at, expiry);
assert!(mint_with(&credentials, &Region::UsEast1, MAX_LIFETIME, expiry).is_err());
}
#[test]
fn debug_prints_the_length_and_never_the_token() {
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_789_000_000);
let minted = mint_with(&credentials(), &Region::UsEast1, MAX_LIFETIME, now).expect("m");
let shown = format!("{:?}", minted.token);
assert!(shown.starts_with("BearerToken(<"), "{shown}");
assert!(!shown.contains(PREFIX), "{shown}");
assert!(!shown.contains("X-Amz"), "{shown}");
}
}