From 27d0906d21e8ae7c3ae1637e18df454670ef98a4 Mon Sep 17 00:00:00 2001 From: "j.foucher" Date: Fri, 29 May 2026 17:00:05 +0200 Subject: [PATCH] ElevenLabs: secure private agents (signed URL) + encrypt key at rest Builds on the client-provided-key SaveGame work to actually secure usage: - Private agents: connect via a short-lived Signed URL fetched with the stored key (GET /v1/convai/conversation/get-signed-url), instead of a direct agent_id+header connection which ElevenLabs rejects for auth-enabled agents. Falls back to a direct connection only when no key is set (public agents). Enforces 'valid key required': no key -> no signed URL -> no conversation. - Force agent creation/update to private (platform_settings.auth.enable_auth=true) so a public, key-less agent can't be created by mistake. - Encrypt the API key at rest in the SaveGame (AES-256); plaintext kept only in the in-memory cache. - Redact signed-URL query (auth token) from logs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../PS_AI_ConvAgent_BlueprintLibrary.cpp | 82 +++++++++-- ...AI_ConvAgent_WebSocket_ElevenLabsProxy.cpp | 135 ++++++++++++++++-- .../PS_AI_ConvAgent_SaveGame_ElevenLabs.h | 12 +- ...S_AI_ConvAgent_WebSocket_ElevenLabsProxy.h | 8 ++ ...nt_AgentConfigCustomization_ElevenLabs.cpp | 14 ++ 5 files changed, 231 insertions(+), 20 deletions(-) diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/PS_AI_ConvAgent_BlueprintLibrary.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/PS_AI_ConvAgent_BlueprintLibrary.cpp index 868c296..10207b6 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/PS_AI_ConvAgent_BlueprintLibrary.cpp +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/PS_AI_ConvAgent_BlueprintLibrary.cpp @@ -5,17 +5,81 @@ #include "PS_AI_ConvAgent_SaveGame_ElevenLabs.h" #include "Components/SkeletalMeshComponent.h" #include "Kismet/GameplayStatics.h" +#include "Misc/AES.h" namespace { - // In-memory cache of the client API key. This BlueprintLibrary is the single - // owner of the key — every consumer (WebSocket auth, region probe, editor sync - // tools) reads it via GetElevenLabsAPIKey(). The SaveGame on disk is the - // persistent backing store; this cache avoids a disk read on every access. - // All access is on the game thread, so no synchronization is needed. + // In-memory cache of the client API key (PLAINTEXT). This BlueprintLibrary is + // the single owner of the key — every consumer (WebSocket auth, region probe, + // editor sync tools) reads it via GetElevenLabsAPIKey(). The SaveGame on disk + // is the persistent backing store (AES-encrypted); this cache avoids a disk + // read + decrypt on every access. All access is on the game thread, so no + // synchronization is needed. bool GElevenLabsKeyLoaded = false; FString GElevenLabsKeyCache; + // 32-byte AES key used to obfuscate the API key at rest in the .sav file. + // IMPORTANT: this is embedded in the binary, so it is NOT real security — a + // determined attacker can extract it. It only raises the bar against casual + // inspection of the save file (agreed "trusted distribution" threat model). + void GetObfuscationAESKey(FAES::FAESKey& OutKey) + { + static const uint8 KeyBytes[FAES::FAESKey::KeySize] = { + 0x9A, 0x3C, 0x71, 0x05, 0xE2, 0x4F, 0xB8, 0x16, + 0x2D, 0xC4, 0x88, 0x5B, 0x37, 0xF1, 0x60, 0xAA, + 0x14, 0x7E, 0xD9, 0x42, 0x6B, 0x90, 0x0C, 0xF5, + 0x53, 0x21, 0xBE, 0x8D, 0x47, 0xA0, 0x39, 0xCF + }; + FMemory::Memcpy(OutKey.Key, KeyBytes, FAES::FAESKey::KeySize); + } + + /** Encrypt a plaintext key to AES block-padded bytes. Sets OutPlainLen to the + * original UTF-8 byte length so padding can be stripped on decrypt. */ + TArray EncryptElevenLabsKey(const FString& Plain, int32& OutPlainLen) + { + FTCHARToUTF8 Utf8(*Plain); + OutPlainLen = Utf8.Length(); + + TArray Buffer; + Buffer.Append(reinterpret_cast(Utf8.Get()), Utf8.Length()); + if (Buffer.Num() == 0) + { + return Buffer; // empty key → empty blob + } + + // Pad up to a multiple of the AES block size (required by FAES). + const int32 Remainder = Buffer.Num() % FAES::AESBlockSize; + if (Remainder != 0) + { + Buffer.AddZeroed(FAES::AESBlockSize - Remainder); + } + + FAES::FAESKey Key; + GetObfuscationAESKey(Key); + FAES::EncryptData(Buffer.GetData(), Buffer.Num(), Key); + return Buffer; + } + + /** Decrypt AES block-padded bytes back to the original plaintext key. */ + FString DecryptElevenLabsKey(const TArray& Encrypted, int32 PlainLen) + { + if (Encrypted.Num() == 0 || PlainLen <= 0 || (Encrypted.Num() % FAES::AESBlockSize) != 0) + { + return FString(); + } + + TArray Buffer = Encrypted; + FAES::FAESKey Key; + GetObfuscationAESKey(Key); + FAES::DecryptData(Buffer.GetData(), Buffer.Num(), Key); + + // Strip padding back to the original UTF-8 length, then null-terminate. + const int32 Len = FMath::Clamp(PlainLen, 0, Buffer.Num()); + Buffer.SetNum(Len); + Buffer.Add(0); + return FString(FUTF8ToTCHAR(reinterpret_cast(Buffer.GetData())).Get()); + } + /** Load the API-key SaveGame from its slot, or null if none exists yet. */ UPS_AI_ConvAgent_SaveGame_ElevenLabs* LoadElevenLabsKeySaveGame() { @@ -60,7 +124,9 @@ void UPS_AI_ConvAgent_BlueprintLibrary::SetElevenLabsAPIKey(const FString& Key) return; } - Save->API_Key = Key; + int32 PlainLen = 0; + Save->EncryptedAPIKey = EncryptElevenLabsKey(Key, PlainLen); + Save->PlainKeyLength = PlainLen; const bool bSaved = UGameplayStatics::SaveGameToSlot( Save, UPS_AI_ConvAgent_SaveGame_ElevenLabs::GetSlotName(), @@ -80,13 +146,13 @@ void UPS_AI_ConvAgent_BlueprintLibrary::SetElevenLabsAPIKey(const FString& Key) FString UPS_AI_ConvAgent_BlueprintLibrary::GetElevenLabsAPIKey() { - // Lazy-load once from the SaveGame, then serve from the in-memory cache. + // Lazy-load once from the SaveGame (decrypting), then serve from the cache. if (!GElevenLabsKeyLoaded) { GElevenLabsKeyLoaded = true; if (const UPS_AI_ConvAgent_SaveGame_ElevenLabs* Save = LoadElevenLabsKeySaveGame()) { - GElevenLabsKeyCache = Save->API_Key; + GElevenLabsKeyCache = DecryptElevenLabsKey(Save->EncryptedAPIKey, Save->PlainKeyLength); } } return GElevenLabsKeyCache; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/PS_AI_ConvAgent_WebSocket_ElevenLabsProxy.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/PS_AI_ConvAgent_WebSocket_ElevenLabsProxy.cpp index e4b109e..e816392 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/PS_AI_ConvAgent_WebSocket_ElevenLabsProxy.cpp +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/PS_AI_ConvAgent_WebSocket_ElevenLabsProxy.cpp @@ -10,6 +10,10 @@ #include "Json.h" #include "Misc/Base64.h" +#include "HttpModule.h" +#include "Interfaces/IHttpRequest.h" +#include "Interfaces/IHttpResponse.h" + DEFINE_LOG_CATEGORY_STATIC(LogPS_AI_ConvAgent_WS_ElevenLabs, Log, All); // ───────────────────────────────────────────────────────────────────────────── @@ -29,7 +33,36 @@ void UPS_AI_ConvAgent_WebSocket_ElevenLabsProxy::Connect(const FString& AgentIDO FModuleManager::LoadModuleChecked("WebSockets"); } - const FString URL = BuildWebSocketURL(AgentIDOverride, APIKeyOverride); + const UPS_AI_ConvAgent_Settings_ElevenLabs* Settings = FPS_AI_ConvAgentModule::Get().GetSettings(); + const FString ResolvedKey = APIKeyOverride.IsEmpty() + ? UPS_AI_ConvAgent_BlueprintLibrary::GetElevenLabsAPIKey() + : APIKeyOverride; + + // Private agents (authentication enabled) require a Signed URL: a direct + // ?agent_id=... connection is rejected by ElevenLabs even with the xi-api-key + // header. So whenever we have a key (and no manual URL override), fetch a + // short-lived signed URL with that key and connect to it. This also works for + // public agents, and enforces "valid key required" — no valid key, no signed + // URL, no conversation. + if (!ResolvedKey.IsEmpty() && Settings->CustomWebSocketURL.IsEmpty()) + { + if (AgentIDOverride.IsEmpty()) + { + const FString Msg = TEXT("Cannot connect: no Agent ID configured. Set it in Project Settings or pass it to Connect()."); + UE_LOG(LogPS_AI_ConvAgent_WS_ElevenLabs, Error, TEXT("%s"), *Msg); + ConnectionState = EPS_AI_ConvAgent_ConnectionState_ElevenLabs::Error; + OnError.Broadcast(Msg); + return; + } + + ConnectionState = EPS_AI_ConvAgent_ConnectionState_ElevenLabs::Connecting; + RequestSignedURLAndConnect(AgentIDOverride, ResolvedKey); + return; + } + + // Fallback: direct connection. Used for the advanced custom-URL override, or + // for a public agent when no key is available (no authentication). + const FString URL = BuildWebSocketURL(AgentIDOverride, ResolvedKey); if (URL.IsEmpty()) { const FString Msg = TEXT("Cannot connect: no Agent ID configured. Set it in Project Settings or pass it to Connect()."); @@ -39,21 +72,105 @@ void UPS_AI_ConvAgent_WebSocket_ElevenLabsProxy::Connect(const FString& AgentIDO return; } - UE_LOG(LogPS_AI_ConvAgent_WS_ElevenLabs, Log, TEXT("Connecting to ElevenLabs: %s"), *URL); ConnectionState = EPS_AI_ConvAgent_ConnectionState_ElevenLabs::Connecting; - // Headers: the ElevenLabs Conversational AI WS endpoint accepts the - // xi-api-key header on the initial HTTP upgrade request. - // The key comes from the client-provided SaveGame (via the BlueprintLibrary); - // an explicit override passed to Connect() still takes precedence. TMap UpgradeHeaders; - const FString ResolvedKey = APIKeyOverride.IsEmpty() - ? UPS_AI_ConvAgent_BlueprintLibrary::GetElevenLabsAPIKey() - : APIKeyOverride; if (!ResolvedKey.IsEmpty()) { UpgradeHeaders.Add(TEXT("xi-api-key"), ResolvedKey); } + OpenWebSocket(URL, UpgradeHeaders); +} + +void UPS_AI_ConvAgent_WebSocket_ElevenLabsProxy::RequestSignedURLAndConnect(const FString& AgentID, const FString& APIKey) +{ + const UPS_AI_ConvAgent_Settings_ElevenLabs* Settings = FPS_AI_ConvAgentModule::Get().GetSettings(); + const FString RequestURL = FString::Printf( + TEXT("%s/v1/convai/conversation/get-signed-url?agent_id=%s"), + *Settings->GetAPIBaseURL(), *AgentID); + + UE_LOG(LogPS_AI_ConvAgent_WS_ElevenLabs, Log, TEXT("Requesting signed URL for agent %s"), *AgentID); + + TSharedRef Request = FHttpModule::Get().CreateRequest(); + Request->SetURL(RequestURL); + Request->SetVerb(TEXT("GET")); + Request->SetHeader(TEXT("xi-api-key"), APIKey); + + TWeakObjectPtr WeakThis(this); + Request->OnProcessRequestComplete().BindLambda( + [WeakThis](FHttpRequestPtr /*Req*/, FHttpResponsePtr Resp, bool bSuccess) + { + if (!WeakThis.IsValid()) + { + return; + } + UPS_AI_ConvAgent_WebSocket_ElevenLabsProxy* Self = WeakThis.Get(); + + const int32 Code = Resp.IsValid() ? Resp->GetResponseCode() : 0; + if (bSuccess && Code == 200 && Resp.IsValid()) + { + FString SignedURL; + TSharedPtr Root; + const TSharedRef> Reader = TJsonReaderFactory<>::Create(Resp->GetContentAsString()); + if (FJsonSerializer::Deserialize(Reader, Root) && Root.IsValid()) + { + Root->TryGetStringField(TEXT("signed_url"), SignedURL); + } + + if (!SignedURL.IsEmpty()) + { + // Token is embedded in the signed URL — no auth header needed. + Self->OpenWebSocket(SignedURL, TMap()); + return; + } + + const FString Msg = TEXT("Signed URL request succeeded but the response had no 'signed_url'."); + UE_LOG(LogPS_AI_ConvAgent_WS_ElevenLabs, Error, TEXT("%s"), *Msg); + Self->ConnectionState = EPS_AI_ConvAgent_ConnectionState_ElevenLabs::Error; + Self->OnError.Broadcast(Msg); + return; + } + + FString Msg; + if (Code == 401 || Code == 403) + { + Msg = FString::Printf(TEXT("Authentication failed (HTTP %d): invalid or unauthorized ElevenLabs API key."), Code); + } + else if (Code == 0) + { + Msg = TEXT("Could not reach ElevenLabs to obtain a signed URL (network error)."); + } + else + { + Msg = FString::Printf(TEXT("Failed to obtain a signed URL (HTTP %d)."), Code); + } + UE_LOG(LogPS_AI_ConvAgent_WS_ElevenLabs, Error, TEXT("%s"), *Msg); + Self->ConnectionState = EPS_AI_ConvAgent_ConnectionState_ElevenLabs::Error; + Self->OnError.Broadcast(Msg); + }); + + Request->ProcessRequest(); +} + +void UPS_AI_ConvAgent_WebSocket_ElevenLabsProxy::OpenWebSocket(const FString& URL, const TMap& UpgradeHeaders) +{ + if (URL.IsEmpty()) + { + const FString Msg = TEXT("Cannot open WebSocket: empty URL."); + UE_LOG(LogPS_AI_ConvAgent_WS_ElevenLabs, Error, TEXT("%s"), *Msg); + ConnectionState = EPS_AI_ConvAgent_ConnectionState_ElevenLabs::Error; + OnError.Broadcast(Msg); + return; + } + + // Redact the query string (signed URLs carry an auth token there) before logging. + FString LogURL = URL; + int32 QueryIdx = INDEX_NONE; + if (URL.FindChar(TEXT('?'), QueryIdx)) + { + LogURL = URL.Left(QueryIdx) + TEXT("?