Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Utc};
|
||||
use vec1::Vec1;
|
||||
|
||||
use warp_graphql::managed_secrets::{ManagedSecret, ManagedSecretConfig, ManagedSecretType};
|
||||
|
||||
pub use warp_graphql::queries::task_secrets::ManagedSecretValue;
|
||||
|
||||
/// An OIDC identity token issued for a task workload.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TaskIdentityToken {
|
||||
/// The signed OIDC JWT.
|
||||
pub token: String,
|
||||
/// When the token expires.
|
||||
pub expires_at: DateTime<Utc>,
|
||||
/// The OIDC issuer that signed the token.
|
||||
pub issuer: String,
|
||||
}
|
||||
|
||||
/// Options for issuing an OIDC identity token.
|
||||
pub struct IdentityTokenOptions {
|
||||
/// The intended audience for the token (e.g. a cloud provider URL).
|
||||
pub audience: String,
|
||||
/// The requested token lifetime. The server may cap this to a maximum value.
|
||||
pub requested_duration: Duration,
|
||||
/// Controls how the `sub` claim is formatted. Each element names a claim to
|
||||
/// include.
|
||||
pub subject_template: Vec1<String>,
|
||||
}
|
||||
|
||||
/// Configuration for all managed secret stores accessible to the current user.
|
||||
#[derive(Debug)]
|
||||
pub struct ManagedSecretConfigs {
|
||||
/// Configuration for the user's personal secrets.
|
||||
pub user_secrets: Option<ManagedSecretConfig>,
|
||||
/// Configuration for all team secret stores that the user can access.
|
||||
pub team_secrets: HashMap<String, ManagedSecretConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SecretOwner {
|
||||
CurrentUser,
|
||||
Team { team_uid: String },
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
pub trait ManagedSecretsClient: 'static + Send + Sync {
|
||||
async fn get_managed_secret_configs(&self) -> Result<ManagedSecretConfigs>;
|
||||
|
||||
async fn create_managed_secret(
|
||||
&self,
|
||||
owner: SecretOwner,
|
||||
name: String,
|
||||
secret_type: ManagedSecretType,
|
||||
encrypted_value: String,
|
||||
description: Option<String>,
|
||||
) -> Result<ManagedSecret>;
|
||||
|
||||
async fn delete_managed_secret(&self, owner: SecretOwner, name: String) -> Result<()>;
|
||||
|
||||
async fn update_managed_secret(
|
||||
&self,
|
||||
owner: SecretOwner,
|
||||
name: String,
|
||||
encrypted_value: Option<String>,
|
||||
description: Option<String>,
|
||||
) -> Result<ManagedSecret>;
|
||||
|
||||
async fn list_secrets(&self) -> Result<Vec<ManagedSecret>>;
|
||||
|
||||
async fn get_task_secrets(
|
||||
&self,
|
||||
task_id: String,
|
||||
workload_token: String,
|
||||
) -> Result<HashMap<String, ManagedSecretValue>>;
|
||||
|
||||
/// Issue a short-lived OIDC identity token for the current task.
|
||||
///
|
||||
/// The workload token is not passed explicitly - it's automatically provided
|
||||
/// as part of the client's cloud agent workload identity token support
|
||||
/// (see the `ServerApi` implementation).
|
||||
async fn issue_task_identity_token(
|
||||
&self,
|
||||
options: IdentityTokenOptions,
|
||||
) -> Result<TaskIdentityToken>;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
use std::{io, sync::Once};
|
||||
|
||||
use base64::Engine;
|
||||
use warp_graphql::managed_secrets::ManagedSecretType;
|
||||
|
||||
use crate::secret_value::ManagedSecretValue;
|
||||
|
||||
mod hpke_impl;
|
||||
|
||||
static INIT: Once = Once::new();
|
||||
|
||||
/// Initialize cryptography providers for secret enveloping. This is safe to call multiple times.
|
||||
pub fn init() {
|
||||
INIT.call_once(|| {
|
||||
tink_hybrid::init();
|
||||
|
||||
use hpke_impl::{HpkePrivateKeyManager, HpkePublicKeyManager};
|
||||
use std::sync::Arc;
|
||||
tink_core::registry::register_key_manager(Arc::new(HpkePublicKeyManager::new()))
|
||||
.expect("unable to register HPKE public key manager");
|
||||
tink_core::registry::register_key_manager(Arc::new(HpkePrivateKeyManager::new()))
|
||||
.expect("unable to register HPKE private key manager");
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum EnvelopeError {
|
||||
#[error("invalid public key")]
|
||||
InvalidPublicKey(#[source] anyhow::Error),
|
||||
#[error("cryptography operation failed")]
|
||||
Tink(#[from] tink_core::TinkError),
|
||||
#[error("failed to marshal secret value")]
|
||||
MarshalSecretValue(#[source] serde_json::Error),
|
||||
}
|
||||
|
||||
/// UploadKey is the client-side (public) representation of the tink keysets used to encrypt
|
||||
/// secrets before uploading to the server.
|
||||
pub struct UploadKey {
|
||||
#[allow(dead_code)]
|
||||
public_key: tink_core::keyset::Handle,
|
||||
encrypt: Box<dyn tink_core::HybridEncrypt>,
|
||||
}
|
||||
|
||||
impl UploadKey {
|
||||
pub fn import_public_keyset(public_key: &str) -> Result<Self, EnvelopeError> {
|
||||
let key_bytes = base64::prelude::BASE64_STANDARD
|
||||
.decode(public_key)
|
||||
.map_err(|e| EnvelopeError::InvalidPublicKey(anyhow::anyhow!(e)))?;
|
||||
let mut key_reader = tink_core::keyset::BinaryReader::new(io::Cursor::new(key_bytes));
|
||||
|
||||
let keyset = tink_core::keyset::Handle::read_with_no_secrets(&mut key_reader)?;
|
||||
|
||||
let primitive = tink_hybrid::new_encrypt(&keyset)?;
|
||||
Ok(UploadKey {
|
||||
public_key: keyset,
|
||||
encrypt: primitive,
|
||||
})
|
||||
}
|
||||
|
||||
/// Encrypt a secret value for uploading to the server.
|
||||
pub fn encrypt_secret(
|
||||
&self,
|
||||
actor_uid: &str,
|
||||
secret_name: &str,
|
||||
secret: &ManagedSecretValue,
|
||||
) -> Result<String, EnvelopeError> {
|
||||
let context = UploadContext {
|
||||
actor_uid,
|
||||
secret_name,
|
||||
secret_type: secret.secret_type(),
|
||||
};
|
||||
|
||||
let secret_plaintext =
|
||||
serde_json::to_vec(secret).map_err(EnvelopeError::MarshalSecretValue)?;
|
||||
|
||||
let encrypted = self
|
||||
.encrypt
|
||||
.encrypt(&secret_plaintext, context.encode().as_bytes())?;
|
||||
Ok(base64::prelude::BASE64_STANDARD.encode(encrypted))
|
||||
}
|
||||
}
|
||||
|
||||
struct UploadContext<'a> {
|
||||
actor_uid: &'a str,
|
||||
secret_name: &'a str,
|
||||
secret_type: ManagedSecretType,
|
||||
}
|
||||
|
||||
impl<'a> UploadContext<'a> {
|
||||
fn encode(&self) -> String {
|
||||
// This must match the context encoding format used by the server.
|
||||
format!(
|
||||
"1:{}:{}:{}",
|
||||
self.actor_uid,
|
||||
self.secret_name,
|
||||
self.secret_type.envelope_name()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "envelope_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,315 @@
|
||||
//! HPKE key manager implementations using the pure-Rust [`hpke`] crate.
|
||||
//!
|
||||
//! Supports both encryption (public key manager) and decryption (private key manager).
|
||||
|
||||
use hpke::{
|
||||
Deserializable, OpModeR, OpModeS, Serializable,
|
||||
aead::{AesGcm128, AesGcm256, ChaCha20Poly1305},
|
||||
kdf::HkdfSha256,
|
||||
kem::X25519HkdfSha256,
|
||||
};
|
||||
use rand::{SeedableRng as _, rngs::StdRng};
|
||||
use tink_core::TinkError;
|
||||
use tink_proto::{HpkeAead, HpkeKdf, HpkeKem, prost::Message};
|
||||
|
||||
pub const HPKE_PUBLIC_KEY_TYPE_URL: &str = "type.googleapis.com/google.crypto.tink.HpkePublicKey";
|
||||
pub const HPKE_PRIVATE_KEY_TYPE_URL: &str = "type.googleapis.com/google.crypto.tink.HpkePrivateKey";
|
||||
const HPKE_PUBLIC_KEY_KEY_VERSION: u32 = 0;
|
||||
const HPKE_PRIVATE_KEY_KEY_VERSION: u32 = 0;
|
||||
|
||||
// ── Cipher suite ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Validated HPKE cipher suite. Adding a new combination requires adding a
|
||||
/// variant here and handling it in both [`HpkeSuite::seal`] and
|
||||
/// [`HpkeSuite::open`], so the compiler enforces exhaustive handling.
|
||||
#[derive(Clone, Copy)]
|
||||
enum HpkeSuite {
|
||||
X25519Sha256Aes256Gcm,
|
||||
X25519Sha256Aes128Gcm,
|
||||
X25519Sha256Chacha20Poly1305,
|
||||
}
|
||||
|
||||
impl HpkeSuite {
|
||||
fn seal(
|
||||
self,
|
||||
public_key: &<X25519HkdfSha256 as hpke::Kem>::PublicKey,
|
||||
context_info: &[u8],
|
||||
plaintext: &[u8],
|
||||
) -> Result<Vec<u8>, TinkError> {
|
||||
let mut rng = StdRng::from_os_rng();
|
||||
|
||||
macro_rules! do_seal {
|
||||
($aead_ty:ty) => {{
|
||||
let (encapped_key, ciphertext) =
|
||||
hpke::single_shot_seal::<$aead_ty, HkdfSha256, X25519HkdfSha256, _>(
|
||||
&OpModeS::Base,
|
||||
public_key,
|
||||
context_info,
|
||||
plaintext,
|
||||
&[],
|
||||
&mut rng,
|
||||
)
|
||||
.map_err(|e| TinkError::from(format!("HpkeSuite::seal failed: {e:?}")))?;
|
||||
let mut output = encapped_key.to_bytes().to_vec();
|
||||
output.extend_from_slice(&ciphertext);
|
||||
Ok(output)
|
||||
}};
|
||||
}
|
||||
|
||||
match self {
|
||||
HpkeSuite::X25519Sha256Aes256Gcm => do_seal!(AesGcm256),
|
||||
HpkeSuite::X25519Sha256Aes128Gcm => do_seal!(AesGcm128),
|
||||
HpkeSuite::X25519Sha256Chacha20Poly1305 => do_seal!(ChaCha20Poly1305),
|
||||
}
|
||||
}
|
||||
|
||||
fn open(
|
||||
self,
|
||||
private_key: &<X25519HkdfSha256 as hpke::Kem>::PrivateKey,
|
||||
context_info: &[u8],
|
||||
ciphertext: &[u8],
|
||||
) -> Result<Vec<u8>, TinkError> {
|
||||
let (enc_bytes, encrypted_data) = ciphertext
|
||||
.split_at_checked(X25519_ENCAPPED_KEY_LEN)
|
||||
.ok_or_else(|| TinkError::from("HpkeSuite::open: ciphertext too short"))?;
|
||||
|
||||
macro_rules! do_open {
|
||||
($aead_ty:ty) => {{
|
||||
let encapped_key =
|
||||
<X25519HkdfSha256 as hpke::Kem>::EncappedKey::from_bytes(enc_bytes)
|
||||
.map_err(|_| TinkError::new("HpkeSuite::open: invalid encapped key"))?;
|
||||
hpke::single_shot_open::<$aead_ty, HkdfSha256, X25519HkdfSha256>(
|
||||
&OpModeR::Base,
|
||||
private_key,
|
||||
&encapped_key,
|
||||
context_info,
|
||||
encrypted_data,
|
||||
&[],
|
||||
)
|
||||
.map_err(|e| TinkError::from(format!("HpkeSuite::open failed: {e:?}")))
|
||||
}};
|
||||
}
|
||||
|
||||
match self {
|
||||
HpkeSuite::X25519Sha256Aes256Gcm => do_open!(AesGcm256),
|
||||
HpkeSuite::X25519Sha256Aes128Gcm => do_open!(AesGcm128),
|
||||
HpkeSuite::X25519Sha256Chacha20Poly1305 => do_open!(ChaCha20Poly1305),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The X25519 encapsulated key is always 32 bytes.
|
||||
const X25519_ENCAPPED_KEY_LEN: usize = 32;
|
||||
|
||||
// ── Public key manager (encryption) ─────────────────────────────────────────
|
||||
|
||||
pub(crate) struct HpkePublicKeyManager;
|
||||
|
||||
impl HpkePublicKeyManager {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl tink_core::registry::KeyManager for HpkePublicKeyManager {
|
||||
fn primitive(&self, serialized_key: &[u8]) -> Result<tink_core::Primitive, TinkError> {
|
||||
if serialized_key.is_empty() {
|
||||
return Err(TinkError::new("HpkePublicKeyManager: invalid key"));
|
||||
}
|
||||
|
||||
let key = tink_proto::HpkePublicKey::decode(serialized_key).map_err(|e| {
|
||||
TinkError::from(format!("HpkePublicKeyManager: invalid public key: {e:#}"))
|
||||
})?;
|
||||
let suite = validate_public_key(&key)?;
|
||||
|
||||
let pk = <X25519HkdfSha256 as hpke::Kem>::PublicKey::from_bytes(&key.public_key).map_err(
|
||||
|_| TinkError::new("HpkePublicKeyManager: failed to deserialize public key"),
|
||||
)?;
|
||||
|
||||
Ok(tink_core::Primitive::HybridEncrypt(Box::new(
|
||||
HpkeHybridEncrypt {
|
||||
public_key: pk,
|
||||
suite,
|
||||
},
|
||||
)))
|
||||
}
|
||||
|
||||
fn new_key(&self, _serialized_key_format: &[u8]) -> Result<Vec<u8>, TinkError> {
|
||||
Err(TinkError::new(
|
||||
"HpkePublicKeyManager: new_key not implemented",
|
||||
))
|
||||
}
|
||||
|
||||
fn type_url(&self) -> &'static str {
|
||||
HPKE_PUBLIC_KEY_TYPE_URL
|
||||
}
|
||||
|
||||
fn key_material_type(&self) -> tink_proto::key_data::KeyMaterialType {
|
||||
tink_proto::key_data::KeyMaterialType::AsymmetricPublic
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct HpkeHybridEncrypt {
|
||||
public_key: <X25519HkdfSha256 as hpke::Kem>::PublicKey,
|
||||
suite: HpkeSuite,
|
||||
}
|
||||
|
||||
impl tink_core::HybridEncrypt for HpkeHybridEncrypt {
|
||||
fn encrypt(&self, plaintext: &[u8], context_info: &[u8]) -> Result<Vec<u8>, TinkError> {
|
||||
self.suite.seal(&self.public_key, context_info, plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private key manager (decryption) ────────────────────────────────────────
|
||||
|
||||
pub(crate) struct HpkePrivateKeyManager;
|
||||
|
||||
impl HpkePrivateKeyManager {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl tink_core::registry::KeyManager for HpkePrivateKeyManager {
|
||||
fn primitive(&self, serialized_key: &[u8]) -> Result<tink_core::Primitive, TinkError> {
|
||||
if serialized_key.is_empty() {
|
||||
return Err(TinkError::new("HpkePrivateKeyManager: invalid key"));
|
||||
}
|
||||
|
||||
let key = tink_proto::HpkePrivateKey::decode(serialized_key).map_err(|e| {
|
||||
TinkError::from(format!("HpkePrivateKeyManager: invalid private key: {e:#}"))
|
||||
})?;
|
||||
let suite = validate_private_key(&key)?;
|
||||
|
||||
let sk = <X25519HkdfSha256 as hpke::Kem>::PrivateKey::from_bytes(&key.private_key)
|
||||
.map_err(|_| {
|
||||
TinkError::new("HpkePrivateKeyManager: failed to deserialize private key")
|
||||
})?;
|
||||
|
||||
Ok(tink_core::Primitive::HybridDecrypt(Box::new(
|
||||
HpkeHybridDecrypt {
|
||||
private_key: sk,
|
||||
suite,
|
||||
},
|
||||
)))
|
||||
}
|
||||
|
||||
fn new_key(&self, _serialized_key_format: &[u8]) -> Result<Vec<u8>, TinkError> {
|
||||
Err(TinkError::new(
|
||||
"HpkePrivateKeyManager: new_key not implemented",
|
||||
))
|
||||
}
|
||||
|
||||
fn type_url(&self) -> &'static str {
|
||||
HPKE_PRIVATE_KEY_TYPE_URL
|
||||
}
|
||||
|
||||
fn key_material_type(&self) -> tink_proto::key_data::KeyMaterialType {
|
||||
tink_proto::key_data::KeyMaterialType::AsymmetricPrivate
|
||||
}
|
||||
|
||||
fn supports_private_keys(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn public_key_data(
|
||||
&self,
|
||||
serialized_priv_key: &[u8],
|
||||
) -> Result<tink_proto::KeyData, TinkError> {
|
||||
let priv_key = tink_proto::HpkePrivateKey::decode(serialized_priv_key).map_err(|e| {
|
||||
TinkError::from(format!("HpkePrivateKeyManager: invalid private key: {e:#}"))
|
||||
})?;
|
||||
let mut serialized_pub_key = Vec::new();
|
||||
priv_key
|
||||
.public_key
|
||||
.ok_or_else(|| TinkError::new("HpkePrivateKeyManager: no public key"))?
|
||||
.encode(&mut serialized_pub_key)
|
||||
.map_err(|e| {
|
||||
TinkError::from(format!("HpkePrivateKeyManager: invalid public key: {e:#}"))
|
||||
})?;
|
||||
Ok(tink_proto::KeyData {
|
||||
type_url: HPKE_PUBLIC_KEY_TYPE_URL.to_string(),
|
||||
value: serialized_pub_key,
|
||||
key_material_type: tink_proto::key_data::KeyMaterialType::AsymmetricPublic.into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The `x25519-dalek` `StaticSecret` type already implements `Zeroize`, so the
|
||||
/// private key bytes are securely cleared when this struct is dropped.
|
||||
#[derive(Clone)]
|
||||
struct HpkeHybridDecrypt {
|
||||
private_key: <X25519HkdfSha256 as hpke::Kem>::PrivateKey,
|
||||
suite: HpkeSuite,
|
||||
}
|
||||
|
||||
impl tink_core::HybridDecrypt for HpkeHybridDecrypt {
|
||||
fn decrypt(&self, ciphertext: &[u8], context_info: &[u8]) -> Result<Vec<u8>, TinkError> {
|
||||
self.suite.open(&self.private_key, context_info, ciphertext)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validation helpers ──────────────────────────────────────────────────────
|
||||
|
||||
fn validate_public_key(key: &tink_proto::HpkePublicKey) -> Result<HpkeSuite, TinkError> {
|
||||
tink_core::keyset::validate_key_version(key.version, HPKE_PUBLIC_KEY_KEY_VERSION)?;
|
||||
let params = key
|
||||
.params
|
||||
.as_ref()
|
||||
.ok_or_else(|| TinkError::new("no params"))?;
|
||||
validate_key_params(params)
|
||||
}
|
||||
|
||||
fn validate_private_key(key: &tink_proto::HpkePrivateKey) -> Result<HpkeSuite, TinkError> {
|
||||
tink_core::keyset::validate_key_version(key.version, HPKE_PRIVATE_KEY_KEY_VERSION)?;
|
||||
let pub_key = key
|
||||
.public_key
|
||||
.as_ref()
|
||||
.ok_or_else(|| TinkError::new("no public key"))?;
|
||||
tink_core::keyset::validate_key_version(pub_key.version, HPKE_PUBLIC_KEY_KEY_VERSION)?;
|
||||
let params = pub_key
|
||||
.params
|
||||
.as_ref()
|
||||
.ok_or_else(|| TinkError::new("no params"))?;
|
||||
validate_key_params(params)
|
||||
}
|
||||
|
||||
/// Validate HPKE parameters and return the resolved [`HpkeSuite`].
|
||||
///
|
||||
/// Adding a new supported suite requires adding a variant to [`HpkeSuite`] and
|
||||
/// handling it in both `seal` and `open`, so the compiler enforces completeness.
|
||||
fn validate_key_params(params: &tink_proto::HpkeParams) -> Result<HpkeSuite, TinkError> {
|
||||
let kem = match HpkeKem::try_from(params.kem) {
|
||||
Ok(HpkeKem::DhkemX25519HkdfSha256) => HpkeKem::DhkemX25519HkdfSha256,
|
||||
Ok(HpkeKem::KemUnknown) => return Err(TinkError::new("unknown KEM")),
|
||||
Err(_) => return Err(TinkError::new("unrecognized KEM value")),
|
||||
};
|
||||
|
||||
let kdf = match HpkeKdf::try_from(params.kdf) {
|
||||
Ok(HpkeKdf::HkdfSha256) => HpkeKdf::HkdfSha256,
|
||||
Ok(HpkeKdf::KdfUnknown) => return Err(TinkError::new("unknown KDF")),
|
||||
Err(_) => return Err(TinkError::new("unrecognized KDF value")),
|
||||
};
|
||||
|
||||
let aead = match HpkeAead::try_from(params.aead) {
|
||||
Ok(HpkeAead::Aes256Gcm) => HpkeAead::Aes256Gcm,
|
||||
Ok(HpkeAead::Aes128Gcm) => HpkeAead::Aes128Gcm,
|
||||
Ok(HpkeAead::Chacha20Poly1305) => HpkeAead::Chacha20Poly1305,
|
||||
Ok(HpkeAead::AeadUnknown) => return Err(TinkError::new("unknown AEAD")),
|
||||
Err(_) => return Err(TinkError::new("unrecognized AEAD value")),
|
||||
};
|
||||
|
||||
match (kem, kdf, aead) {
|
||||
(HpkeKem::DhkemX25519HkdfSha256, HpkeKdf::HkdfSha256, HpkeAead::Aes256Gcm) => {
|
||||
Ok(HpkeSuite::X25519Sha256Aes256Gcm)
|
||||
}
|
||||
(HpkeKem::DhkemX25519HkdfSha256, HpkeKdf::HkdfSha256, HpkeAead::Aes128Gcm) => {
|
||||
Ok(HpkeSuite::X25519Sha256Aes128Gcm)
|
||||
}
|
||||
(HpkeKem::DhkemX25519HkdfSha256, HpkeKdf::HkdfSha256, HpkeAead::Chacha20Poly1305) => {
|
||||
Ok(HpkeSuite::X25519Sha256Chacha20Poly1305)
|
||||
}
|
||||
_ => Err(TinkError::new("unsupported HPKE suite combination")),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
use std::io;
|
||||
|
||||
use tink_proto::{KeysetInfo, keyset_info::KeyInfo};
|
||||
|
||||
use crate::ManagedSecretValue;
|
||||
|
||||
use super::UploadKey;
|
||||
|
||||
#[test]
|
||||
fn test_import_public_keyset() {
|
||||
super::init();
|
||||
|
||||
// A base64-encoded public keyset as returned by warp-server.
|
||||
let public_key = "COvInMIBEnAKZAo0dHlwZS5nb29nbGVhcGlzLmNvbS9nb29nbGUuY3J5cHRvLnRpbmsuSHBrZVB1YmxpY0tleRIqEgYIARABGAIaIHRaibhtYbpEfh2CSpdDPhh/6lCBnfoO3nqBmZ3VQGJyGAMQARjryJzCASAB";
|
||||
let upload_key =
|
||||
UploadKey::import_public_keyset(public_key).expect("unable to import public keyset");
|
||||
|
||||
let keyset_info = upload_key.public_key.keyset_info();
|
||||
assert_eq!(
|
||||
keyset_info,
|
||||
KeysetInfo {
|
||||
primary_key_id: 407315563,
|
||||
key_info: vec![KeyInfo {
|
||||
key_id: 407315563,
|
||||
status: tink_proto::KeyStatusType::Enabled.into(),
|
||||
type_url: "type.googleapis.com/google.crypto.tink.HpkePublicKey".to_string(),
|
||||
output_prefix_type: tink_proto::OutputPrefixType::Tink.into(),
|
||||
}],
|
||||
}
|
||||
);
|
||||
|
||||
let encrypted = upload_key
|
||||
.encrypt
|
||||
.encrypt(b"hello from rust", b"rust context")
|
||||
.expect("unable to encrypt");
|
||||
assert!(!encrypted.is_empty());
|
||||
}
|
||||
|
||||
/// An HPKE private key for use in tests.
|
||||
///
|
||||
/// Created with:
|
||||
/// ```sh
|
||||
/// $ java -jar /opt/homebrew/Cellar/tinkey/1.12.0/bin/tinkey_deploy.jar create-keyset --key-template DHKEM_X25519_HKDF_SHA256_HKDF_SHA256_AES_256_GCM --out-format json | jq .
|
||||
/// ```
|
||||
const TEST_PRIVATE_KEY: &str = r#"
|
||||
{
|
||||
"primaryKeyId": 625520774,
|
||||
"key": [
|
||||
{
|
||||
"keyData": {
|
||||
"typeUrl": "type.googleapis.com/google.crypto.tink.HpkePrivateKey",
|
||||
"value": "EioSBggBEAEYAhogGHVh0Tju/DHOWEgpuUJ+9P/pXa5tK16udRWoJJwHbnIaIHdq5FthS7H4Q6xSLzCEnbf1z/F1+PTQHev/5PJ+pc+m",
|
||||
"keyMaterialType": "ASYMMETRIC_PRIVATE"
|
||||
},
|
||||
"status": "ENABLED",
|
||||
"keyId": 625520774,
|
||||
"outputPrefixType": "TINK"
|
||||
}
|
||||
]
|
||||
}
|
||||
"#;
|
||||
|
||||
/// An HPKE public key for use in tests, corresponding to [`TEST_PRIVATE_KEY`].
|
||||
///
|
||||
/// Created with:
|
||||
/// ```sh
|
||||
/// $ java -jar /opt/homebrew/Cellar/tinkey/1.12.0/bin/tinkey_deploy.jar create-public-keyset | jq .
|
||||
/// < private key JSON on stdin >
|
||||
/// ```
|
||||
const TEST_PUBLIC_KEY: &str = r#"
|
||||
{
|
||||
"primaryKeyId": 625520774,
|
||||
"key": [
|
||||
{
|
||||
"keyData": {
|
||||
"typeUrl": "type.googleapis.com/google.crypto.tink.HpkePublicKey",
|
||||
"value": "EgYIARABGAIaIBh1YdE47vwxzlhIKblCfvT/6V2ubSternUVqCScB25y",
|
||||
"keyMaterialType": "ASYMMETRIC_PUBLIC"
|
||||
},
|
||||
"status": "ENABLED",
|
||||
"keyId": 625520774,
|
||||
"outputPrefixType": "TINK"
|
||||
}
|
||||
]
|
||||
}
|
||||
"#;
|
||||
|
||||
/// Test encrypting a managed secret value.
|
||||
#[test]
|
||||
fn test_encrypt_managed_secret() {
|
||||
super::init();
|
||||
|
||||
let keyset = read_keyset_json(TEST_PUBLIC_KEY);
|
||||
let upload_key = UploadKey {
|
||||
encrypt: tink_hybrid::new_encrypt(&keyset).expect("failed to create encrypt primitive"),
|
||||
public_key: keyset,
|
||||
};
|
||||
|
||||
let encrypted = upload_key
|
||||
.encrypt_secret(
|
||||
"user123",
|
||||
"MY_SECRET",
|
||||
&ManagedSecretValue::RawValue {
|
||||
value: "secret".to_string(),
|
||||
},
|
||||
)
|
||||
.expect("failed to encrypt secret");
|
||||
assert!(!encrypted.is_empty());
|
||||
}
|
||||
|
||||
/// Test our HPKE encryption and decryption primitives. At the very least, they should be able to roundtrip a plaintext value.
|
||||
#[test]
|
||||
fn test_encrypt_decrypt() {
|
||||
super::init();
|
||||
|
||||
let private_key = read_keyset_json(TEST_PRIVATE_KEY);
|
||||
let public_key = read_keyset_json(TEST_PUBLIC_KEY);
|
||||
|
||||
let encrypt =
|
||||
tink_hybrid::new_encrypt(&public_key).expect("failed to create encrypt primitive");
|
||||
let decrypt =
|
||||
tink_hybrid::new_decrypt(&private_key).expect("failed to create decrypt primitive");
|
||||
|
||||
let context = b"I am context";
|
||||
let plaintext = b"hello from rust";
|
||||
|
||||
let encrypted = encrypt
|
||||
.encrypt(plaintext, context)
|
||||
.expect("failed to encrypt");
|
||||
let decrypted = decrypt
|
||||
.decrypt(&encrypted, context)
|
||||
.expect("failed to decrypt");
|
||||
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
fn read_keyset_json(json: &str) -> tink_core::keyset::Handle {
|
||||
let mut reader = tink_core::keyset::JsonReader::new(io::Cursor::new(json.as_bytes()));
|
||||
tink_core::keyset::insecure::read(&mut reader).expect("failed to read keyset")
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
ffi::OsString,
|
||||
io::Write,
|
||||
path::{Path, PathBuf},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use tempfile::NamedTempFile;
|
||||
use warp_core::safe_debug;
|
||||
|
||||
use crate::client::TaskIdentityToken;
|
||||
|
||||
const GCP_WORKLOAD_IDENTITY_FEDERATION_VERSION: u8 = 1;
|
||||
pub(crate) const TOKEN_TYPE_ID_TOKEN: &str = "urn:ietf:params:oauth:token-type:id_token";
|
||||
pub(crate) const TOKEN_TYPE_JWT: &str = "urn:ietf:params:oauth:token-type:jwt";
|
||||
|
||||
/// Configuration for GCP Workload Identity Federation.
|
||||
///
|
||||
/// These fields map directly to the GCP concepts required to set up
|
||||
/// executable-sourced external credentials
|
||||
/// ([AIP-4117](https://google.aip.dev/auth/4117)).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GcpFederationConfig {
|
||||
/// GCP project number (not project ID).
|
||||
pub project_number: String,
|
||||
/// Workload identity pool ID.
|
||||
pub pool_id: String,
|
||||
/// Workload identity pool provider ID.
|
||||
pub provider_id: String,
|
||||
/// Service account email for impersonation. When set, the federated token
|
||||
/// is exchanged for a service account access token.
|
||||
pub service_account_email: Option<String>,
|
||||
/// Lifetime for the impersonated service account token.
|
||||
pub token_lifetime: Option<Duration>,
|
||||
}
|
||||
|
||||
/// Handle for GCP Workload Identity Federation credentials.
|
||||
///
|
||||
/// The handle represents a GCP authentication config file that uses Workload
|
||||
/// Identity Federation to authenticate to GCP as a particular Oz agent run.
|
||||
///
|
||||
/// When the handle is dropped, the backing temporary files are deleted.
|
||||
pub struct GcpCredentials {
|
||||
/// Temporary file holding the GCP credentials configuration file.
|
||||
config_file: NamedTempFile,
|
||||
/// Temporary file where Warp OIDC tokens are cached.
|
||||
output_file: NamedTempFile,
|
||||
}
|
||||
|
||||
impl GcpCredentials {
|
||||
/// Create executable-sourced GCP federation credentials for the given task.
|
||||
///
|
||||
/// This writes an [AIP-4117](https://google.aip.dev/auth/4117)
|
||||
/// `external_account` config to a temporary file and returns the
|
||||
/// environment variables required for ADC to discover it.
|
||||
pub fn federated(
|
||||
task_id: &str,
|
||||
config: &GcpFederationConfig,
|
||||
) -> Result<Self, PrepareGcpCredentialsError> {
|
||||
safe_debug!(
|
||||
safe: ("Configuring GCP workload identity federation"),
|
||||
full: ("Configuring GCP workload identity federation for project={}, pool={}, provider={}", config.project_number, config.pool_id, config.provider_id)
|
||||
);
|
||||
|
||||
let oz_binary_path =
|
||||
std::env::current_exe().map_err(|_| PrepareGcpCredentialsError::NoBinaryPath)?;
|
||||
|
||||
// Create the output file that the executable will write cached tokens to.
|
||||
let output_file = NamedTempFile::new()
|
||||
.map_err(|source| PrepareGcpCredentialsError::FileCreate { source })?;
|
||||
|
||||
let cred_config_json =
|
||||
generate_gcp_credential_config(task_id, config, &oz_binary_path, output_file.path())?;
|
||||
safe_debug!(
|
||||
safe: ("Generated federated GCP configuration"),
|
||||
full: ("Generated federated GCP configuration: {cred_config_json:#}")
|
||||
);
|
||||
|
||||
let json_bytes = serde_json::to_vec_pretty(&cred_config_json)
|
||||
.map_err(PrepareGcpCredentialsError::SerializeConfig)?;
|
||||
|
||||
let mut config_file = NamedTempFile::new()
|
||||
.map_err(|source| PrepareGcpCredentialsError::FileCreate { source })?;
|
||||
config_file.write_all(&json_bytes).map_err(|source| {
|
||||
PrepareGcpCredentialsError::FileWrite {
|
||||
path: config_file.path().to_path_buf(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
safe_debug!(
|
||||
safe: ("Wrote GCP credentials config file"),
|
||||
full: ("Wrote GCP credentials to {}", config_file.path().display())
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
config_file,
|
||||
output_file,
|
||||
})
|
||||
}
|
||||
|
||||
/// Environment variables to set in a session in order to use this GCP
|
||||
/// configuration.
|
||||
pub fn env_vars(&self) -> HashMap<OsString, OsString> {
|
||||
let config_file_path = self.config_file.path().as_os_str();
|
||||
let mut vars = HashMap::with_capacity(3);
|
||||
vars.insert(
|
||||
OsString::from("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES"),
|
||||
OsString::from("1"),
|
||||
);
|
||||
// Google Cloud SDKs use the `GOOGLE_APPLICATION_CREDENTIALS` variable.
|
||||
vars.insert(
|
||||
OsString::from("GOOGLE_APPLICATION_CREDENTIALS"),
|
||||
config_file_path.to_owned(),
|
||||
);
|
||||
// The `gcloud` CLI has its own auth system, but accepts credential file overrides.
|
||||
vars.insert(
|
||||
OsString::from("CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE"),
|
||||
config_file_path.to_owned(),
|
||||
);
|
||||
vars
|
||||
}
|
||||
|
||||
/// Clean up the GCP credential state. This will remove the temporary configuration and
|
||||
/// token files.
|
||||
pub fn cleanup(self) -> std::io::Result<()> {
|
||||
self.config_file.close()?;
|
||||
self.output_file.close()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PrepareGcpCredentialsError {
|
||||
#[error("Could not determine the current executable path")]
|
||||
NoBinaryPath,
|
||||
#[error("Cannot use executable {} for GCP executable-sourced credentials", path.display())]
|
||||
InvalidBinaryPath { path: PathBuf },
|
||||
#[error("Cannot use run {task_id} for GCP executable-sourced credentials")]
|
||||
InvalidTaskId { task_id: String },
|
||||
#[error("Failed to create credential config file: {source}")]
|
||||
FileCreate {
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("Failed to serialize credential config: {0}")]
|
||||
SerializeConfig(#[source] serde_json::Error),
|
||||
#[error("Failed to write credential config to {path}: {source}")]
|
||||
FileWrite {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
}
|
||||
|
||||
/// Successful output payload for GCP executable-sourced credentials.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct GcpWorkloadIdentityFederationToken {
|
||||
pub version: u8,
|
||||
pub success: bool,
|
||||
pub token_type: String,
|
||||
pub id_token: String,
|
||||
pub expiration_time: i64,
|
||||
}
|
||||
|
||||
impl GcpWorkloadIdentityFederationToken {
|
||||
pub(crate) fn new(token: TaskIdentityToken, token_type: String) -> Self {
|
||||
Self {
|
||||
version: GCP_WORKLOAD_IDENTITY_FEDERATION_VERSION,
|
||||
success: true,
|
||||
token_type,
|
||||
id_token: token.token,
|
||||
expiration_time: token.expires_at.timestamp(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Error output payload for GCP executable-sourced credentials.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct GcpWorkloadIdentityFederationError {
|
||||
pub version: u8,
|
||||
pub success: bool,
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl GcpWorkloadIdentityFederationError {
|
||||
pub(crate) fn new(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
version: GCP_WORKLOAD_IDENTITY_FEDERATION_VERSION,
|
||||
success: false,
|
||||
code: "TOKEN_ISSUANCE_FAILED".into(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn gcp_workload_identity_federation_audience(
|
||||
project_number: &str,
|
||||
pool_id: &str,
|
||||
provider_id: &str,
|
||||
) -> String {
|
||||
format!(
|
||||
"//iam.googleapis.com/projects/{project_number}/locations/global/workloadIdentityPools/{pool_id}/providers/{provider_id}"
|
||||
)
|
||||
}
|
||||
|
||||
/// Produce the [AIP-4117](https://google.aip.dev/auth/4117) `external_account`
|
||||
/// credential configuration JSON.
|
||||
///
|
||||
/// The returned value can be written to
|
||||
/// `$HOME/.config/gcloud/application_default_credentials.json` so that any GCP
|
||||
/// SDK picks it up automatically.
|
||||
///
|
||||
/// `oz_binary_path` should be the absolute path to the current `oz` executable.
|
||||
fn generate_gcp_credential_config(
|
||||
task_id: &str,
|
||||
config: &GcpFederationConfig,
|
||||
oz_binary_path: &Path,
|
||||
output_file: &Path,
|
||||
) -> Result<Value, PrepareGcpCredentialsError> {
|
||||
let audience = gcp_workload_identity_federation_audience(
|
||||
&config.project_number,
|
||||
&config.pool_id,
|
||||
&config.provider_id,
|
||||
);
|
||||
|
||||
let oz_binary_display = oz_binary_path.display().to_string();
|
||||
// The executable command is embedded as a single string in the credential
|
||||
// config. GCP SDKs split it with `strings.Fields` (Go) or `shlex.split`
|
||||
// (Python), so whitespace in either the binary path or the task ID would
|
||||
// cause the command to be misparsed.
|
||||
if oz_binary_display.contains(' ') {
|
||||
return Err(PrepareGcpCredentialsError::InvalidBinaryPath {
|
||||
path: oz_binary_path.to_path_buf(),
|
||||
});
|
||||
}
|
||||
if task_id.contains(' ') {
|
||||
return Err(PrepareGcpCredentialsError::InvalidTaskId {
|
||||
task_id: task_id.to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut command = format!("{oz_binary_display} federate issue-gcp-token --run-id {task_id}");
|
||||
if let Some(lifetime) = config.token_lifetime {
|
||||
command.push_str(&format!(" --duration {}s", lifetime.as_secs()));
|
||||
}
|
||||
|
||||
let mut cred_config = json!({
|
||||
"type": "external_account",
|
||||
"audience": audience,
|
||||
"subject_token_type": "urn:ietf:params:oauth:token-type:id_token",
|
||||
// Regional STS endpoints with workload identity federation are pre-GA and we do not yet
|
||||
// support them:
|
||||
// https://docs.cloud.google.com/iam/docs/best-practices-for-using-workload-identity-federation#sts-regional-endpoints
|
||||
"token_url": "https://sts.googleapis.com/v1/token",
|
||||
"credential_source": {
|
||||
"executable": {
|
||||
"command": command,
|
||||
"timeout_millis": 30000,
|
||||
"output_file": output_file.display().to_string()
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(email) = &config.service_account_email {
|
||||
let impersonation_url = format!(
|
||||
"https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{email}:generateAccessToken"
|
||||
);
|
||||
cred_config["service_account_impersonation_url"] = json!(impersonation_url);
|
||||
|
||||
if let Some(lifetime) = config.token_lifetime {
|
||||
cred_config["service_account_impersonation"] = json!({
|
||||
"token_lifetime_seconds": lifetime.as_secs()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(cred_config)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "gcp_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,153 @@
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::{GcpFederationConfig, PrepareGcpCredentialsError, generate_gcp_credential_config};
|
||||
|
||||
#[test]
|
||||
fn basic_config_shape() {
|
||||
let config = GcpFederationConfig {
|
||||
project_number: "123456789".to_string(),
|
||||
pool_id: "my-pool".to_string(),
|
||||
provider_id: "my-provider".to_string(),
|
||||
service_account_email: None,
|
||||
token_lifetime: None,
|
||||
};
|
||||
|
||||
let result = generate_gcp_credential_config(
|
||||
"task-42",
|
||||
&config,
|
||||
Path::new("/usr/bin/oz"),
|
||||
Path::new("/tmp/token_cache"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
json!({
|
||||
"type": "external_account",
|
||||
"audience": "//iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/my-pool/providers/my-provider",
|
||||
"subject_token_type": "urn:ietf:params:oauth:token-type:id_token",
|
||||
"token_url": "https://sts.googleapis.com/v1/token",
|
||||
"credential_source": {
|
||||
"executable": {
|
||||
"command": "/usr/bin/oz federate issue-gcp-token --run-id task-42",
|
||||
"timeout_millis": 30000,
|
||||
"output_file": "/tmp/token_cache"
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_binary_path_with_spaces() {
|
||||
let config = GcpFederationConfig {
|
||||
project_number: "123".to_string(),
|
||||
pool_id: "pool".to_string(),
|
||||
provider_id: "prov".to_string(),
|
||||
service_account_email: None,
|
||||
token_lifetime: None,
|
||||
};
|
||||
|
||||
let result = generate_gcp_credential_config(
|
||||
"task-1",
|
||||
&config,
|
||||
Path::new("/path with spaces/oz"),
|
||||
Path::new("/tmp/out"),
|
||||
);
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(PrepareGcpCredentialsError::InvalidBinaryPath { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_task_id_with_spaces() {
|
||||
let config = GcpFederationConfig {
|
||||
project_number: "123".to_string(),
|
||||
pool_id: "pool".to_string(),
|
||||
provider_id: "prov".to_string(),
|
||||
service_account_email: None,
|
||||
token_lifetime: None,
|
||||
};
|
||||
|
||||
let result = generate_gcp_credential_config(
|
||||
"task with spaces",
|
||||
&config,
|
||||
Path::new("/bin/oz"),
|
||||
Path::new("/tmp/out"),
|
||||
);
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(PrepareGcpCredentialsError::InvalidTaskId { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_account_impersonation() {
|
||||
let config = GcpFederationConfig {
|
||||
project_number: "111".to_string(),
|
||||
pool_id: "pool".to_string(),
|
||||
provider_id: "prov".to_string(),
|
||||
service_account_email: Some("sa@project.iam.gserviceaccount.com".to_string()),
|
||||
token_lifetime: Some(Duration::from_secs(1800)),
|
||||
};
|
||||
|
||||
let result =
|
||||
generate_gcp_credential_config("t-1", &config, Path::new("/bin/oz"), Path::new("/tmp/out"))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
json!({
|
||||
"type": "external_account",
|
||||
"audience": "//iam.googleapis.com/projects/111/locations/global/workloadIdentityPools/pool/providers/prov",
|
||||
"subject_token_type": "urn:ietf:params:oauth:token-type:id_token",
|
||||
"token_url": "https://sts.googleapis.com/v1/token",
|
||||
"credential_source": {
|
||||
"executable": {
|
||||
"command": "/bin/oz federate issue-gcp-token --run-id t-1 --duration 1800s",
|
||||
"timeout_millis": 30000,
|
||||
"output_file": "/tmp/out"
|
||||
}
|
||||
},
|
||||
"service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/sa@project.iam.gserviceaccount.com:generateAccessToken",
|
||||
"service_account_impersonation": {
|
||||
"token_lifetime_seconds": 1800
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_duration_flag_when_lifetime_absent() {
|
||||
let config = GcpFederationConfig {
|
||||
project_number: "333".to_string(),
|
||||
pool_id: "pool".to_string(),
|
||||
provider_id: "prov".to_string(),
|
||||
service_account_email: None,
|
||||
token_lifetime: None,
|
||||
};
|
||||
|
||||
let result =
|
||||
generate_gcp_credential_config("t-3", &config, Path::new("/bin/oz"), Path::new("/tmp/out"))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
json!({
|
||||
"type": "external_account",
|
||||
"audience": "//iam.googleapis.com/projects/333/locations/global/workloadIdentityPools/pool/providers/prov",
|
||||
"subject_token_type": "urn:ietf:params:oauth:token-type:id_token",
|
||||
"token_url": "https://sts.googleapis.com/v1/token",
|
||||
"credential_source": {
|
||||
"executable": {
|
||||
"command": "/bin/oz federate issue-gcp-token --run-id t-3",
|
||||
"timeout_millis": 30000,
|
||||
"output_file": "/tmp/out"
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
pub mod client;
|
||||
mod envelope;
|
||||
mod gcp;
|
||||
mod manager;
|
||||
mod secret_value;
|
||||
|
||||
pub use client::TaskIdentityToken;
|
||||
pub use envelope::{UploadKey, init as init_envelope};
|
||||
pub use gcp::{
|
||||
GcpCredentials, GcpFederationConfig, GcpWorkloadIdentityFederationError,
|
||||
GcpWorkloadIdentityFederationToken, PrepareGcpCredentialsError,
|
||||
};
|
||||
pub use manager::{ActorProvider, ManagedSecretManager};
|
||||
pub use secret_value::ManagedSecretValue;
|
||||
@@ -0,0 +1,280 @@
|
||||
use std::{collections::HashMap, future::Future, sync::Arc, time::Duration};
|
||||
|
||||
use vec1::vec1;
|
||||
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warp_graphql::managed_secrets::ManagedSecret;
|
||||
use warpui::{Entity, SingletonEntity};
|
||||
|
||||
use crate::{
|
||||
ManagedSecretValue,
|
||||
client::{
|
||||
IdentityTokenOptions, ManagedSecretConfigs, ManagedSecretsClient, SecretOwner,
|
||||
TaskIdentityToken,
|
||||
},
|
||||
envelope::UploadKey,
|
||||
gcp::{self, GcpWorkloadIdentityFederationError, GcpWorkloadIdentityFederationToken},
|
||||
};
|
||||
use warp_graphql::queries::task_secrets::ManagedSecretValue as GqlManagedSecretValue;
|
||||
|
||||
/// Singleton model for working with Warp-managed secrets.
|
||||
pub struct ManagedSecretManager {
|
||||
client: Arc<dyn ManagedSecretsClient>,
|
||||
actor_provider: Arc<dyn ActorProvider>,
|
||||
}
|
||||
|
||||
pub trait ActorProvider: Send + Sync + 'static {
|
||||
fn actor_uid(&self) -> Option<String>;
|
||||
}
|
||||
|
||||
impl ManagedSecretManager {
|
||||
pub fn new(
|
||||
client: Arc<dyn ManagedSecretsClient>,
|
||||
actor_provider: Arc<dyn ActorProvider>,
|
||||
) -> Self {
|
||||
crate::envelope::init();
|
||||
Self {
|
||||
client,
|
||||
actor_provider,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_secret(
|
||||
&self,
|
||||
owner: SecretOwner,
|
||||
name: String,
|
||||
value: ManagedSecretValue,
|
||||
description: Option<String>,
|
||||
) -> impl Future<Output = anyhow::Result<ManagedSecret>> + use<> {
|
||||
let client = self.client.clone();
|
||||
let actor_provider = self.actor_provider.clone();
|
||||
async move {
|
||||
if !FeatureFlag::WarpManagedSecrets.is_enabled() {
|
||||
return Err(anyhow::anyhow!("This feature is not enabled"));
|
||||
}
|
||||
// We retrieve all upload keys on demand. These should potentially be fetched and stored
|
||||
// ahead of time instead.
|
||||
let configs = client.get_managed_secret_configs().await?;
|
||||
|
||||
let Some(actor) = actor_provider.actor_uid() else {
|
||||
return Err(anyhow::anyhow!("No authenticated user"));
|
||||
};
|
||||
|
||||
// Chain errors so that we don't hold an `UploadKey` handle across an `.await`.
|
||||
let encrypted_value = owner_public_key(&configs, &owner)
|
||||
.and_then(|public_key| {
|
||||
UploadKey::import_public_keyset(public_key).map_err(anyhow::Error::from)
|
||||
})
|
||||
.and_then(|public_key| {
|
||||
public_key
|
||||
.encrypt_secret(&actor, &name, &value)
|
||||
.map_err(anyhow::Error::from)
|
||||
})?;
|
||||
|
||||
let managed_secret = client
|
||||
.create_managed_secret(
|
||||
owner,
|
||||
name,
|
||||
value.secret_type(),
|
||||
encrypted_value,
|
||||
description,
|
||||
)
|
||||
.await?;
|
||||
Ok(managed_secret)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_secret(
|
||||
&self,
|
||||
owner: SecretOwner,
|
||||
name: String,
|
||||
) -> impl Future<Output = anyhow::Result<()>> + use<> {
|
||||
let client = self.client.clone();
|
||||
async move {
|
||||
if !FeatureFlag::WarpManagedSecrets.is_enabled() {
|
||||
return Err(anyhow::anyhow!("This feature is not enabled"));
|
||||
}
|
||||
|
||||
client.delete_managed_secret(owner, name).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_secret(
|
||||
&self,
|
||||
owner: SecretOwner,
|
||||
name: String,
|
||||
value: Option<ManagedSecretValue>,
|
||||
description: Option<String>,
|
||||
) -> impl Future<Output = anyhow::Result<ManagedSecret>> + use<> {
|
||||
let client = self.client.clone();
|
||||
let actor_provider = self.actor_provider.clone();
|
||||
async move {
|
||||
if !FeatureFlag::WarpManagedSecrets.is_enabled() {
|
||||
return Err(anyhow::anyhow!("This feature is not enabled"));
|
||||
}
|
||||
|
||||
let encrypted_value = if let Some(value) = value {
|
||||
// We retrieve all upload keys on demand. These should potentially be fetched and stored
|
||||
// ahead of time instead.
|
||||
let configs = client.get_managed_secret_configs().await?;
|
||||
|
||||
let Some(actor) = actor_provider.actor_uid() else {
|
||||
return Err(anyhow::anyhow!("No authenticated user"));
|
||||
};
|
||||
|
||||
// Chain errors so that we don't hold an `UploadKey` handle across an `.await`.
|
||||
let encrypted = owner_public_key(&configs, &owner)
|
||||
.and_then(|public_key| {
|
||||
UploadKey::import_public_keyset(public_key).map_err(anyhow::Error::from)
|
||||
})
|
||||
.and_then(|public_key| {
|
||||
public_key
|
||||
.encrypt_secret(&actor, &name, &value)
|
||||
.map_err(anyhow::Error::from)
|
||||
})?;
|
||||
Some(encrypted)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let managed_secret = client
|
||||
.update_managed_secret(owner, name, encrypted_value, description)
|
||||
.await?;
|
||||
Ok(managed_secret)
|
||||
}
|
||||
}
|
||||
|
||||
/// List all managed secrets accessible to the current user.
|
||||
pub fn list_secrets(&self) -> impl Future<Output = anyhow::Result<Vec<ManagedSecret>>> + use<> {
|
||||
let client = self.client.clone();
|
||||
async move {
|
||||
let secrets = client.list_secrets().await?;
|
||||
Ok(secrets)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get Warp-managed secrets scoped to the currently-executing task.
|
||||
///
|
||||
/// This will fail if not in an ambient agent.
|
||||
pub fn get_task_secrets(
|
||||
&self,
|
||||
task_id: String,
|
||||
) -> impl Future<Output = anyhow::Result<HashMap<String, ManagedSecretValue>>> + use<> {
|
||||
let client = self.client.clone();
|
||||
async move {
|
||||
// We only need the workload token for the duration of the request.
|
||||
let workload_token =
|
||||
warp_isolation_platform::issue_workload_token(Some(Duration::from_mins(5))).await?;
|
||||
let gql_secrets = client
|
||||
.get_task_secrets(task_id, workload_token.token)
|
||||
.await?;
|
||||
|
||||
// Convert GQL ManagedSecretValue to our ManagedSecretValue
|
||||
let mut secrets = HashMap::new();
|
||||
for (name, gql_value) in gql_secrets {
|
||||
let value = match gql_value {
|
||||
GqlManagedSecretValue::ManagedSecretRawValue(raw) => {
|
||||
ManagedSecretValue::raw_value(raw.value)
|
||||
}
|
||||
GqlManagedSecretValue::ManagedSecretAnthropicApiKeyValue(v) => {
|
||||
ManagedSecretValue::anthropic_api_key(v.api_key)
|
||||
}
|
||||
GqlManagedSecretValue::ManagedSecretAnthropicBedrockAccessKeyValue(v) => {
|
||||
ManagedSecretValue::anthropic_bedrock_access_key(
|
||||
v.aws_access_key_id,
|
||||
v.aws_secret_access_key,
|
||||
// aws_session_token is now optional on the server.
|
||||
v.aws_session_token,
|
||||
v.aws_region,
|
||||
)
|
||||
}
|
||||
GqlManagedSecretValue::ManagedSecretAnthropicBedrockApiKeyValue(v) => {
|
||||
ManagedSecretValue::anthropic_bedrock_api_key(
|
||||
v.aws_bearer_token_bedrock,
|
||||
v.aws_region,
|
||||
)
|
||||
}
|
||||
GqlManagedSecretValue::Unknown => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Unknown secret value type for secret: {}",
|
||||
name
|
||||
));
|
||||
}
|
||||
};
|
||||
secrets.insert(name, value);
|
||||
}
|
||||
Ok(secrets)
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue a short-lived OIDC identity token for the current task.
|
||||
pub fn issue_task_identity_token(
|
||||
&self,
|
||||
options: IdentityTokenOptions,
|
||||
) -> impl Future<Output = anyhow::Result<TaskIdentityToken>> + use<> {
|
||||
let client = self.client.clone();
|
||||
async move { client.issue_task_identity_token(options).await }
|
||||
}
|
||||
|
||||
/// Issue a short-lived OIDC identity token in the JSON shape expected by
|
||||
/// GCP executable-sourced Workload Identity Federation credentials.
|
||||
pub fn issue_gcp_workload_identity_federation_token(
|
||||
&self,
|
||||
audience: String,
|
||||
token_type: String,
|
||||
requested_duration: Duration,
|
||||
) -> impl Future<
|
||||
Output = Result<GcpWorkloadIdentityFederationToken, GcpWorkloadIdentityFederationError>,
|
||||
> + use<> {
|
||||
let client = self.client.clone();
|
||||
async move {
|
||||
match token_type.as_str() {
|
||||
gcp::TOKEN_TYPE_ID_TOKEN | gcp::TOKEN_TYPE_JWT => (),
|
||||
other => {
|
||||
return Err(GcpWorkloadIdentityFederationError::new(format!(
|
||||
"Unsupported token type `{other}`"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
match client
|
||||
.issue_task_identity_token(IdentityTokenOptions {
|
||||
audience,
|
||||
requested_duration,
|
||||
subject_template: vec1!["principal".to_owned()],
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(token) => Ok(GcpWorkloadIdentityFederationToken::new(token, token_type)),
|
||||
Err(err) => Err(GcpWorkloadIdentityFederationError::new(err.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the public upload key corresponding to `owner`.
|
||||
/// Returns an error if there's no such key in `configs`.
|
||||
fn owner_public_key<'a>(
|
||||
configs: &'a ManagedSecretConfigs,
|
||||
owner: &SecretOwner,
|
||||
) -> Result<&'a str, anyhow::Error> {
|
||||
match owner {
|
||||
SecretOwner::CurrentUser => configs
|
||||
.user_secrets
|
||||
.as_ref()
|
||||
.and_then(|config| config.public_key.as_deref())
|
||||
.ok_or_else(|| anyhow::anyhow!("No public key for user")),
|
||||
SecretOwner::Team { team_uid } => configs
|
||||
.team_secrets
|
||||
.get(team_uid)
|
||||
.and_then(|config| config.public_key.as_deref())
|
||||
.ok_or_else(|| anyhow::anyhow!("No public key for team {team_uid}")),
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ManagedSecretManager {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for ManagedSecretManager {}
|
||||
@@ -0,0 +1,101 @@
|
||||
use std::fmt;
|
||||
|
||||
use serde::Serialize;
|
||||
use warp_graphql::managed_secrets::ManagedSecretType;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum ManagedSecretValue {
|
||||
RawValue {
|
||||
value: String,
|
||||
},
|
||||
AnthropicApiKey {
|
||||
api_key: String,
|
||||
},
|
||||
AnthropicBedrockAccessKey {
|
||||
aws_access_key_id: String,
|
||||
aws_secret_access_key: String,
|
||||
/// Optional AWS session token. Only required for temporary/STS credentials;
|
||||
/// persistent IAM access keys do not need one. When `None`, the field is
|
||||
/// omitted from the serialized JSON payload sent to the server.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
aws_session_token: Option<String>,
|
||||
aws_region: String,
|
||||
},
|
||||
AnthropicBedrockApiKey {
|
||||
aws_bearer_token_bedrock: String,
|
||||
aws_region: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl ManagedSecretValue {
|
||||
pub fn raw_value(s: impl Into<String>) -> Self {
|
||||
Self::RawValue { value: s.into() }
|
||||
}
|
||||
|
||||
pub fn anthropic_api_key(s: impl Into<String>) -> Self {
|
||||
Self::AnthropicApiKey { api_key: s.into() }
|
||||
}
|
||||
|
||||
/// Construct an Anthropic Bedrock access key secret from IAM credentials and AWS region.
|
||||
///
|
||||
/// `session_token` is optional and may be `None` for persistent IAM credentials
|
||||
/// that do not require a session token.
|
||||
pub fn anthropic_bedrock_access_key(
|
||||
access_key_id: impl Into<String>,
|
||||
secret_access_key: impl Into<String>,
|
||||
session_token: Option<String>,
|
||||
region: impl Into<String>,
|
||||
) -> Self {
|
||||
Self::AnthropicBedrockAccessKey {
|
||||
aws_access_key_id: access_key_id.into(),
|
||||
aws_secret_access_key: secret_access_key.into(),
|
||||
aws_session_token: session_token,
|
||||
aws_region: region.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct an Anthropic Bedrock API key secret from a bearer token and AWS region.
|
||||
pub fn anthropic_bedrock_api_key(token: impl Into<String>, region: impl Into<String>) -> Self {
|
||||
Self::AnthropicBedrockApiKey {
|
||||
aws_bearer_token_bedrock: token.into(),
|
||||
aws_region: region.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn secret_type(&self) -> ManagedSecretType {
|
||||
match self {
|
||||
ManagedSecretValue::RawValue { .. } => ManagedSecretType::RawValue,
|
||||
ManagedSecretValue::AnthropicApiKey { .. } => ManagedSecretType::AnthropicApiKey,
|
||||
ManagedSecretValue::AnthropicBedrockAccessKey { .. } => {
|
||||
ManagedSecretType::AnthropicBedrockAccessKey
|
||||
}
|
||||
ManagedSecretValue::AnthropicBedrockApiKey { .. } => {
|
||||
ManagedSecretType::AnthropicBedrockApiKey
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ManagedSecretValue {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
ManagedSecretValue::RawValue { .. } => f
|
||||
.debug_struct("ManagedSecret::RawValue")
|
||||
.finish_non_exhaustive(),
|
||||
ManagedSecretValue::AnthropicApiKey { .. } => f
|
||||
.debug_struct("ManagedSecret::AnthropicApiKey")
|
||||
.finish_non_exhaustive(),
|
||||
ManagedSecretValue::AnthropicBedrockAccessKey { .. } => f
|
||||
.debug_struct("ManagedSecret::AnthropicBedrockAccessKey")
|
||||
.finish_non_exhaustive(),
|
||||
ManagedSecretValue::AnthropicBedrockApiKey { .. } => f
|
||||
.debug_struct("ManagedSecret::AnthropicBedrockApiKey")
|
||||
.finish_non_exhaustive(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "secret_value_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,171 @@
|
||||
use crate::secret_value::ManagedSecretValue;
|
||||
|
||||
/// Test to ensure that `raw_value` secrets are serialized in the format that the server expects.
|
||||
#[test]
|
||||
fn test_serialize_raw_value() {
|
||||
let secret = ManagedSecretValue::RawValue {
|
||||
value: "secret".to_string(),
|
||||
};
|
||||
let serialized = serde_json::to_string(&secret).expect("failed to serialize");
|
||||
assert_eq!(serialized, "{\"value\":\"secret\"}");
|
||||
}
|
||||
|
||||
/// Test to ensure that the [`ManagedSecretValue`] debug representation does not leak the secret value.
|
||||
#[test]
|
||||
fn test_debug_representation_no_secrets() {
|
||||
let secret = ManagedSecretValue::RawValue {
|
||||
value: "secret".to_string(),
|
||||
};
|
||||
let debug_representation = format!("{:?}", secret);
|
||||
assert!(
|
||||
!debug_representation.contains("secret"),
|
||||
"debug representation contains secret value: {debug_representation}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test to ensure that `anthropic_api_key` secrets are serialized in the format that the server expects.
|
||||
#[test]
|
||||
fn test_serialize_anthropic_api_key() {
|
||||
let secret = ManagedSecretValue::AnthropicApiKey {
|
||||
api_key: "sk-ant-test-key".to_string(),
|
||||
};
|
||||
let serialized = serde_json::to_string(&secret).expect("failed to serialize");
|
||||
assert_eq!(serialized, "{\"api_key\":\"sk-ant-test-key\"}");
|
||||
}
|
||||
|
||||
/// Test to ensure that the [`ManagedSecretValue::AnthropicApiKey`] debug representation does not leak the API key.
|
||||
#[test]
|
||||
fn test_debug_representation_no_secrets_anthropic_api_key() {
|
||||
let secret = ManagedSecretValue::AnthropicApiKey {
|
||||
api_key: "sk-ant-secret-key".to_string(),
|
||||
};
|
||||
let debug_representation = format!("{:?}", secret);
|
||||
assert!(
|
||||
!debug_representation.contains("sk-ant-secret-key"),
|
||||
"debug representation contains secret value: {debug_representation}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test to ensure that `anthropic_bedrock_api_key` secrets are serialized in the format that the server expects.
|
||||
#[test]
|
||||
fn test_serialize_anthropic_bedrock_api_key() {
|
||||
let secret = ManagedSecretValue::AnthropicBedrockApiKey {
|
||||
aws_bearer_token_bedrock: "test-token".to_string(),
|
||||
aws_region: "us-east-1".to_string(),
|
||||
};
|
||||
let serialized = serde_json::to_string(&secret).expect("failed to serialize");
|
||||
assert_eq!(
|
||||
serialized,
|
||||
"{\"aws_bearer_token_bedrock\":\"test-token\",\"aws_region\":\"us-east-1\"}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test to ensure that `anthropic_bedrock_access_key` secrets are serialized in the format that the server expects.
|
||||
#[test]
|
||||
fn test_serialize_anthropic_bedrock_access_key() {
|
||||
let secret = ManagedSecretValue::AnthropicBedrockAccessKey {
|
||||
aws_access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(),
|
||||
aws_secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(),
|
||||
aws_session_token: Some("FwoGZXIvYXdzEBY".to_string()),
|
||||
aws_region: "us-east-1".to_string(),
|
||||
};
|
||||
let serialized = serde_json::to_string(&secret).expect("failed to serialize");
|
||||
assert_eq!(
|
||||
serialized,
|
||||
"{\"aws_access_key_id\":\"AKIAIOSFODNN7EXAMPLE\",\"aws_secret_access_key\":\"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\",\"aws_session_token\":\"FwoGZXIvYXdzEBY\",\"aws_region\":\"us-east-1\"}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test to ensure that an `anthropic_bedrock_access_key` secret with no session
|
||||
/// token (i.e. persistent IAM credentials) omits the `aws_session_token` field
|
||||
/// from the JSON payload sent to the server.
|
||||
#[test]
|
||||
fn test_serialize_anthropic_bedrock_access_key_without_session_token() {
|
||||
let secret = ManagedSecretValue::AnthropicBedrockAccessKey {
|
||||
aws_access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(),
|
||||
aws_secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(),
|
||||
aws_session_token: None,
|
||||
aws_region: "us-east-1".to_string(),
|
||||
};
|
||||
let serialized = serde_json::to_string(&secret).expect("failed to serialize");
|
||||
assert_eq!(
|
||||
serialized,
|
||||
"{\"aws_access_key_id\":\"AKIAIOSFODNN7EXAMPLE\",\"aws_secret_access_key\":\"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\",\"aws_region\":\"us-east-1\"}"
|
||||
);
|
||||
assert!(
|
||||
!serialized.contains("aws_session_token"),
|
||||
"aws_session_token must not appear in serialized JSON when None: {serialized}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test that the constructor helper correctly passes an optional session token through.
|
||||
#[test]
|
||||
fn test_anthropic_bedrock_access_key_constructor_optional_session_token() {
|
||||
let with_token = ManagedSecretValue::anthropic_bedrock_access_key(
|
||||
"AKID",
|
||||
"secret",
|
||||
Some("token".to_string()),
|
||||
"us-east-1",
|
||||
);
|
||||
match with_token {
|
||||
ManagedSecretValue::AnthropicBedrockAccessKey {
|
||||
aws_session_token, ..
|
||||
} => assert_eq!(aws_session_token.as_deref(), Some("token")),
|
||||
_ => panic!("unexpected variant"),
|
||||
}
|
||||
|
||||
let without_token =
|
||||
ManagedSecretValue::anthropic_bedrock_access_key("AKID", "secret", None, "us-east-1");
|
||||
match without_token {
|
||||
ManagedSecretValue::AnthropicBedrockAccessKey {
|
||||
aws_session_token, ..
|
||||
} => assert!(aws_session_token.is_none()),
|
||||
_ => panic!("unexpected variant"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test to ensure that the [`ManagedSecretValue::AnthropicBedrockAccessKey`] debug representation does not leak secrets.
|
||||
#[test]
|
||||
fn test_debug_representation_no_secrets_anthropic_bedrock_access_key() {
|
||||
let secret = ManagedSecretValue::AnthropicBedrockAccessKey {
|
||||
aws_access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(),
|
||||
aws_secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(),
|
||||
aws_session_token: Some("FwoGZXIvYXdzEBY".to_string()),
|
||||
aws_region: "us-west-2".to_string(),
|
||||
};
|
||||
let debug_representation = format!("{:?}", secret);
|
||||
assert!(
|
||||
!debug_representation.contains("AKIAIOSFODNN7EXAMPLE"),
|
||||
"debug representation contains aws_access_key_id: {debug_representation}"
|
||||
);
|
||||
assert!(
|
||||
!debug_representation.contains("wJalrXUtnFEMI"),
|
||||
"debug representation contains aws_secret_access_key: {debug_representation}"
|
||||
);
|
||||
assert!(
|
||||
!debug_representation.contains("FwoGZXIvYXdzEBY"),
|
||||
"debug representation contains aws_session_token: {debug_representation}"
|
||||
);
|
||||
assert!(
|
||||
!debug_representation.contains("us-west-2"),
|
||||
"debug representation contains aws_region: {debug_representation}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test to ensure that the [`ManagedSecretValue::AnthropicBedrockApiKey`] debug representation does not leak secrets.
|
||||
#[test]
|
||||
fn test_debug_representation_no_secrets_anthropic_bedrock_api_key() {
|
||||
let secret = ManagedSecretValue::AnthropicBedrockApiKey {
|
||||
aws_bearer_token_bedrock: "secret-token".to_string(),
|
||||
aws_region: "us-west-2".to_string(),
|
||||
};
|
||||
let debug_representation = format!("{:?}", secret);
|
||||
assert!(
|
||||
!debug_representation.contains("secret-token"),
|
||||
"debug representation contains secret value: {debug_representation}"
|
||||
);
|
||||
assert!(
|
||||
!debug_representation.contains("us-west-2"),
|
||||
"debug representation contains aws_region: {debug_representation}"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user