Compare commits

...

2 Commits

Author SHA1 Message Date
27d0906d21 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) <noreply@anthropic.com>
2026-05-29 17:00:05 +02:00
89d555b734 ElevenLabs: client-provided API key via SaveGame
The API key is no longer stored in project settings / DefaultEngine.ini (it was committed and baked into shipped builds). It is now provided per-install by the client at runtime and persisted in a SaveGame slot.

- Add UPS_AI_ConvAgent_SaveGame_ElevenLabs (slot PS_AI_ConvAgent_ElevenLabs)

- BlueprintLibrary is the single owner of the key: Set/Get/Has/Clear + in-memory cache backed by the SaveGame

- Add async BP node 'Test ElevenLabs API Key' (GET /v1/models)

- Remove API_Key UPROPERTY from UPS_AI_ConvAgent_Settings_ElevenLabs and the key logic from the module

- Route all consumers (WebSocket auth, region probe, editor sync customizations) through GetElevenLabsAPIKey()

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 14:27:29 +02:00
14 changed files with 532 additions and 42 deletions

View File

@@ -181,6 +181,9 @@ bUseManualIPAddress=False
ManualIPAddress=
[/Script/PS_AI_ConvAgent.PS_AI_ConvAgent_Settings_ElevenLabs]
API_Key=7b73c4244ccbec394cc010aaab01b0ec59ce0b11fc636ce4828354f675ca14a5
; NOTE: there is intentionally no API_Key here. The ElevenLabs key is provided
; per-install by the client at runtime (BlueprintLibrary "Set ElevenLabs API Key")
; and stored in a SaveGame slot (Saved/SaveGames/PS_AI_ConvAgent_ElevenLabs.sav),
; so it is never committed or baked into a shipped build.
ServerRegion=Global

View File

@@ -19,6 +19,10 @@ void FPS_AI_ConvAgentModule::StartupModule()
Settings = NewObject<UPS_AI_ConvAgent_Settings_ElevenLabs>(GetTransientPackage(), "PS_AI_ConvAgent_Settings_ElevenLabs", RF_Standalone);
Settings->AddToRoot();
// NOTE: the client-provided API key (if any) is loaded lazily on the first
// GetSettings() call rather than here — see GetSettings(). Loading at module
// startup would be too early: the SaveGame system may not be ready yet.
if (ISettingsModule* SettingsModule = FModuleManager::GetModulePtr<ISettingsModule>("Settings"))
{
SettingsModule->RegisterSettings(

View File

@@ -1,7 +1,98 @@
// Copyright ASTERION. All Rights Reserved.
#include "PS_AI_ConvAgent_BlueprintLibrary.h"
#include "PS_AI_ConvAgent.h"
#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 (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<uint8> EncryptElevenLabsKey(const FString& Plain, int32& OutPlainLen)
{
FTCHARToUTF8 Utf8(*Plain);
OutPlainLen = Utf8.Length();
TArray<uint8> Buffer;
Buffer.Append(reinterpret_cast<const uint8*>(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<uint8>& Encrypted, int32 PlainLen)
{
if (Encrypted.Num() == 0 || PlainLen <= 0 || (Encrypted.Num() % FAES::AESBlockSize) != 0)
{
return FString();
}
TArray<uint8> 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<const ANSICHAR*>(Buffer.GetData())).Get());
}
/** Load the API-key SaveGame from its slot, or null if none exists yet. */
UPS_AI_ConvAgent_SaveGame_ElevenLabs* LoadElevenLabsKeySaveGame()
{
const TCHAR* Slot = UPS_AI_ConvAgent_SaveGame_ElevenLabs::GetSlotName();
const int32 UserIndex = UPS_AI_ConvAgent_SaveGame_ElevenLabs::GetUserIndex();
if (UGameplayStatics::DoesSaveGameExist(Slot, UserIndex))
{
return Cast<UPS_AI_ConvAgent_SaveGame_ElevenLabs>(
UGameplayStatics::LoadGameFromSlot(Slot, UserIndex));
}
return nullptr;
}
}
void UPS_AI_ConvAgent_BlueprintLibrary::SetPostProcessAnimBlueprint(
USkeletalMeshComponent* SkelMeshComp,
@@ -15,3 +106,73 @@ void UPS_AI_ConvAgent_BlueprintLibrary::SetPostProcessAnimBlueprint(
SkelMeshComp->SetOverridePostProcessAnimBP(AnimBPClass);
}
// ── ElevenLabs API key (per-user, client-provided) ──────────────────────────
void UPS_AI_ConvAgent_BlueprintLibrary::SetElevenLabsAPIKey(const FString& Key)
{
// 1) Persist to the SaveGame slot (writes to disk immediately).
UPS_AI_ConvAgent_SaveGame_ElevenLabs* Save = LoadElevenLabsKeySaveGame();
if (!Save)
{
Save = Cast<UPS_AI_ConvAgent_SaveGame_ElevenLabs>(
UGameplayStatics::CreateSaveGameObject(UPS_AI_ConvAgent_SaveGame_ElevenLabs::StaticClass()));
}
if (!Save)
{
UE_LOG(LogTemp, Warning, TEXT("[PS_AI_ConvAgent] SetElevenLabsAPIKey: failed to create SaveGame object."));
return;
}
int32 PlainLen = 0;
Save->EncryptedAPIKey = EncryptElevenLabsKey(Key, PlainLen);
Save->PlainKeyLength = PlainLen;
const bool bSaved = UGameplayStatics::SaveGameToSlot(
Save,
UPS_AI_ConvAgent_SaveGame_ElevenLabs::GetSlotName(),
UPS_AI_ConvAgent_SaveGame_ElevenLabs::GetUserIndex());
if (!bSaved)
{
UE_LOG(LogTemp, Warning, TEXT("[PS_AI_ConvAgent] SetElevenLabsAPIKey: SaveGameToSlot failed "
"(check write permissions at the install location)."));
}
// 2) Update the in-memory cache so the new key takes effect immediately
// (next conversation / region probe / editor sync) without a restart.
GElevenLabsKeyCache = Key;
GElevenLabsKeyLoaded = true;
}
FString UPS_AI_ConvAgent_BlueprintLibrary::GetElevenLabsAPIKey()
{
// 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 = DecryptElevenLabsKey(Save->EncryptedAPIKey, Save->PlainKeyLength);
}
}
return GElevenLabsKeyCache;
}
bool UPS_AI_ConvAgent_BlueprintLibrary::HasElevenLabsAPIKey()
{
return !GetElevenLabsAPIKey().IsEmpty();
}
void UPS_AI_ConvAgent_BlueprintLibrary::ClearElevenLabsAPIKey()
{
const TCHAR* Slot = UPS_AI_ConvAgent_SaveGame_ElevenLabs::GetSlotName();
const int32 UserIndex = UPS_AI_ConvAgent_SaveGame_ElevenLabs::GetUserIndex();
if (UGameplayStatics::DoesSaveGameExist(Slot, UserIndex))
{
UGameplayStatics::DeleteGameInSlot(Slot, UserIndex);
}
// Clear the cache too so the running session stops using the old key.
GElevenLabsKeyCache.Empty();
GElevenLabsKeyLoaded = true;
}

View File

@@ -10,6 +10,7 @@
#include "PS_AI_ConvAgent_InteractionSubsystem.h"
#include "PS_AI_ConvAgent_InteractionComponent.h"
#include "PS_AI_ConvAgent.h"
#include "PS_AI_ConvAgent_BlueprintLibrary.h"
#include "Components/AudioComponent.h"
#include "Sound/SoundAttenuation.h"
@@ -2822,12 +2823,13 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::DrawDebugHUD() const
void UPS_AI_ConvAgent_ElevenLabsComponent::FetchServerRegion()
{
const UPS_AI_ConvAgent_Settings_ElevenLabs* Settings = FPS_AI_ConvAgentModule::Get().GetSettings();
if (!Settings || Settings->API_Key.IsEmpty()) return;
const FString APIKey = UPS_AI_ConvAgent_BlueprintLibrary::GetElevenLabsAPIKey();
if (!Settings || APIKey.IsEmpty()) return;
auto Request = FHttpModule::Get().CreateRequest();
Request->SetURL(Settings->GetAPIBaseURL() + TEXT("/v1/models"));
Request->SetVerb(TEXT("GET"));
Request->SetHeader(TEXT("xi-api-key"), Settings->API_Key);
Request->SetHeader(TEXT("xi-api-key"), APIKey);
TWeakObjectPtr<UPS_AI_ConvAgent_ElevenLabsComponent> WeakThis(this);
Request->OnProcessRequestComplete().BindLambda(

View File

@@ -0,0 +1,78 @@
// Copyright ASTERION. All Rights Reserved.
#include "PS_AI_ConvAgent_TestAPIKey_AsyncAction.h"
#include "PS_AI_ConvAgent.h"
#include "HttpModule.h"
#include "Interfaces/IHttpRequest.h"
#include "Interfaces/IHttpResponse.h"
UPS_AI_ConvAgent_TestAPIKey_AsyncAction* UPS_AI_ConvAgent_TestAPIKey_AsyncAction::TestElevenLabsAPIKey(const FString& Key)
{
UPS_AI_ConvAgent_TestAPIKey_AsyncAction* Action = NewObject<UPS_AI_ConvAgent_TestAPIKey_AsyncAction>();
Action->KeyToTest = Key;
return Action;
}
void UPS_AI_ConvAgent_TestAPIKey_AsyncAction::Activate()
{
// Keep this action alive across the async HTTP round-trip. Removed right
// before SetReadyToDestroy() once a result has been broadcast.
AddToRoot();
if (KeyToTest.IsEmpty())
{
OnInvalid.Broadcast(TEXT("API key is empty."));
RemoveFromRoot();
SetReadyToDestroy();
return;
}
// Respect the configured server region for the probe URL; fall back to global.
FString BaseURL = TEXT("https://api.elevenlabs.io");
if (FPS_AI_ConvAgentModule::IsAvailable())
{
if (const UPS_AI_ConvAgent_Settings_ElevenLabs* Settings = FPS_AI_ConvAgentModule::Get().GetSettings())
{
BaseURL = Settings->GetAPIBaseURL();
}
}
const TSharedRef<IHttpRequest, ESPMode::ThreadSafe> Request = FHttpModule::Get().CreateRequest();
Request->SetURL(BaseURL + TEXT("/v1/models"));
Request->SetVerb(TEXT("GET"));
Request->SetHeader(TEXT("xi-api-key"), KeyToTest);
TWeakObjectPtr<UPS_AI_ConvAgent_TestAPIKey_AsyncAction> WeakThis(this);
Request->OnProcessRequestComplete().BindLambda(
[WeakThis](FHttpRequestPtr /*Req*/, FHttpResponsePtr Resp, bool bSuccess)
{
if (!WeakThis.IsValid())
{
return;
}
UPS_AI_ConvAgent_TestAPIKey_AsyncAction* Self = WeakThis.Get();
const int32 Code = Resp.IsValid() ? Resp->GetResponseCode() : 0;
if (bSuccess && Code == 200)
{
Self->OnValid.Broadcast(TEXT("API key is valid."));
}
else if (Code == 401 || Code == 403)
{
Self->OnInvalid.Broadcast(FString::Printf(TEXT("Invalid API key (HTTP %d)."), Code));
}
else if (Code == 0)
{
Self->OnInvalid.Broadcast(TEXT("Could not reach ElevenLabs (network error)."));
}
else
{
Self->OnInvalid.Broadcast(FString::Printf(TEXT("Key test failed (HTTP %d)."), Code));
}
Self->RemoveFromRoot();
Self->SetReadyToDestroy();
});
Request->ProcessRequest();
}

View File

@@ -2,6 +2,7 @@
#include "PS_AI_ConvAgent_WebSocket_ElevenLabsProxy.h"
#include "PS_AI_ConvAgent.h"
#include "PS_AI_ConvAgent_BlueprintLibrary.h"
#include "WebSocketsModule.h"
#include "IWebSocket.h"
@@ -9,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);
// ─────────────────────────────────────────────────────────────────────────────
@@ -28,7 +33,36 @@ void UPS_AI_ConvAgent_WebSocket_ElevenLabsProxy::Connect(const FString& AgentIDO
FModuleManager::LoadModuleChecked<FWebSocketsModule>("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().");
@@ -38,18 +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.
TMap<FString, FString> UpgradeHeaders;
const UPS_AI_ConvAgent_Settings_ElevenLabs* Settings = FPS_AI_ConvAgentModule::Get().GetSettings();
const FString ResolvedKey = APIKeyOverride.IsEmpty() ? Settings->API_Key : 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<IHttpRequest, ESPMode::ThreadSafe> Request = FHttpModule::Get().CreateRequest();
Request->SetURL(RequestURL);
Request->SetVerb(TEXT("GET"));
Request->SetHeader(TEXT("xi-api-key"), APIKey);
TWeakObjectPtr<UPS_AI_ConvAgent_WebSocket_ElevenLabsProxy> 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<FJsonObject> Root;
const TSharedRef<TJsonReader<>> 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<FString, FString>());
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<FString, FString>& 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("?<params hidden>");
}
UE_LOG(LogPS_AI_ConvAgent_WS_ElevenLabs, Log, TEXT("Connecting to ElevenLabs: %s"), *LogURL);
WebSocket = FWebSocketsModule::Get().CreateWebSocket(URL, TEXT(""), UpgradeHeaders);

View File

@@ -31,13 +31,12 @@ class PS_AI_CONVAGENT_API UPS_AI_ConvAgent_Settings_ElevenLabs : public UObject
GENERATED_BODY()
public:
/**
* ElevenLabs API key.
* Obtain from https://elevenlabs.io used to authenticate WebSocket connections.
* Keep this secret; do not ship with the key hard-coded in a shipping build.
*/
UPROPERTY(Config, EditAnywhere, Category = "ASTERION|PS_AI_ConvAgent|ElevenLabs API")
FString API_Key;
// NOTE: The ElevenLabs API key is NOT stored here on purpose.
// It is provided per-install by the client at runtime and persisted in a
// SaveGame slot (see UPS_AI_ConvAgent_SaveGame_ElevenLabs). Read/write it via
// UPS_AI_ConvAgent_BlueprintLibrary::Get/SetElevenLabsAPIKey(). This keeps the
// key out of Project Settings / DefaultEngine.ini so it can never be committed
// or baked into a shipped build.
/**
* Server region for ElevenLabs API.
@@ -119,7 +118,8 @@ public:
return FModuleManager::Get().IsModuleLoaded("PS_AI_ConvAgent");
}
/** Access the settings object at runtime */
/** Access the settings object at runtime (region, custom URL, logging, global
* prompt). The API key is NOT here — see UPS_AI_ConvAgent_BlueprintLibrary. */
UPS_AI_ConvAgent_Settings_ElevenLabs* GetSettings() const;
private:

View File

@@ -28,4 +28,30 @@ public:
meta = (DisplayName = "Set Post Process Anim Blueprint"))
static void SetPostProcessAnimBlueprint(USkeletalMeshComponent* SkelMeshComp,
TSubclassOf<UAnimInstance> AnimBPClass);
// ── ElevenLabs API key (per-user, client-provided) ────────────────────────
/** Store the client's ElevenLabs API key. Persists it per-user in
* Saved/Config/.../Game.ini AND applies it immediately to the running plugin
* (no restart needed). Call this from your settings UI once the user has
* entered — and ideally validated via "Test ElevenLabs API Key" — their key. */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_ConvAgent|ElevenLabs API Key",
meta = (DisplayName = "Set ElevenLabs API Key"))
static void SetElevenLabsAPIKey(const FString& Key);
/** Return the currently stored client ElevenLabs API key (empty if none). */
UFUNCTION(BlueprintPure, Category = "ASTERION|PS_AI_ConvAgent|ElevenLabs API Key",
meta = (DisplayName = "Get ElevenLabs API Key"))
static FString GetElevenLabsAPIKey();
/** True if a non-empty client ElevenLabs API key has been stored.
* Use this on startup to decide whether to force the user to the key entry screen. */
UFUNCTION(BlueprintPure, Category = "ASTERION|PS_AI_ConvAgent|ElevenLabs API Key",
meta = (DisplayName = "Has ElevenLabs API Key"))
static bool HasElevenLabsAPIKey();
/** Clear the stored client ElevenLabs API key (both on disk and in memory). */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_ConvAgent|ElevenLabs API Key",
meta = (DisplayName = "Clear ElevenLabs API Key"))
static void ClearElevenLabsAPIKey();
};

View File

@@ -0,0 +1,42 @@
// Copyright ASTERION. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/SaveGame.h"
#include "PS_AI_ConvAgent_SaveGame_ElevenLabs.generated.h"
// ─────────────────────────────────────────────────────────────────────────────
// UPS_AI_ConvAgent_SaveGame_ElevenLabs
//
// Per-install persistence for the CLIENT's own ElevenLabs API key. Written via
// UGameplayStatics::SaveGameToSlot to:
// - Editor/PIE : <Project>/Saved/SaveGames/PS_AI_ConvAgent_ElevenLabs.sav
// - Packaged : <Build>/<Project>/Saved/SaveGames/PS_AI_ConvAgent_ElevenLabs.sav
//
// Binary file → the key is never stored in plaintext, never committed, never
// baked into the shipped config. The key is AES-encrypted at rest (see the codec
// in UPS_AI_ConvAgent_BlueprintLibrary) and entered once at runtime via the
// settings UI (BlueprintLibrary "Set ElevenLabs API Key").
// ─────────────────────────────────────────────────────────────────────────────
UCLASS()
class PS_AI_CONVAGENT_API UPS_AI_ConvAgent_SaveGame_ElevenLabs : public USaveGame
{
GENERATED_BODY()
public:
/** AES-encrypted ElevenLabs API key (UTF-8, block-padded). Empty until set.
* Encrypted/decrypted by UPS_AI_ConvAgent_BlueprintLibrary. */
UPROPERTY()
TArray<uint8> EncryptedAPIKey;
/** Plaintext (UTF-8) byte length, used to strip AES block padding on decrypt. */
UPROPERTY()
int32 PlainKeyLength = 0;
/** Fixed save slot name used for the key. */
static const TCHAR* GetSlotName() { return TEXT("PS_AI_ConvAgent_ElevenLabs"); }
/** Fixed user index used for the key slot. */
static constexpr int32 GetUserIndex() { return 0; }
};

View File

@@ -0,0 +1,47 @@
// Copyright ASTERION. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "Kismet/BlueprintAsyncActionBase.h"
#include "PS_AI_ConvAgent_TestAPIKey_AsyncAction.generated.h"
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FPS_AI_ConvAgent_TestAPIKeyResult, const FString&, Message);
// ─────────────────────────────────────────────────────────────────────────────
// UPS_AI_ConvAgent_TestAPIKey_AsyncAction
//
// Latent Blueprint node that validates an ElevenLabs API key by issuing a
// lightweight GET /v1/models request with the key as the xi-api-key header.
// - HTTP 200 → OnValid (key works)
// - HTTP 401 / other → OnInvalid (bad key, or network/server error)
//
// Designed for a settings UI: let the user type their key, run this node, and
// only call "Set ElevenLabs API Key" on the OnValid branch.
// ─────────────────────────────────────────────────────────────────────────────
UCLASS()
class PS_AI_CONVAGENT_API UPS_AI_ConvAgent_TestAPIKey_AsyncAction : public UBlueprintAsyncActionBase
{
GENERATED_BODY()
public:
/** Test an ElevenLabs API key. Pass the key the user just entered in your settings UI. */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_ConvAgent|ElevenLabs API Key",
meta = (BlueprintInternalUseOnly = "true", DisplayName = "Test ElevenLabs API Key"))
static UPS_AI_ConvAgent_TestAPIKey_AsyncAction* TestElevenLabsAPIKey(const FString& Key);
/** Fired when the key is valid (HTTP 200). The Message contains a human-readable status. */
UPROPERTY(BlueprintAssignable)
FPS_AI_ConvAgent_TestAPIKeyResult OnValid;
/** Fired when the key is invalid or the request failed. The Message explains why. */
UPROPERTY(BlueprintAssignable)
FPS_AI_ConvAgent_TestAPIKeyResult OnInvalid;
//~ Begin UBlueprintAsyncActionBase interface
virtual void Activate() override;
//~ End UBlueprintAsyncActionBase interface
private:
FString KeyToTest;
};

View File

@@ -234,6 +234,14 @@ private:
/** Resolve the WebSocket URL from settings / parameters. */
FString BuildWebSocketURL(const FString& AgentID, const FString& APIKey) const;
/** Fetch a short-lived signed URL for a (private) agent using the API key,
* then open the WebSocket on it. Required for agents with authentication
* enabled — a direct ?agent_id=... connection is rejected by ElevenLabs. */
void RequestSignedURLAndConnect(const FString& AgentID, const FString& APIKey);
/** Create the WebSocket on the given URL, bind callbacks, and connect. */
void OpenWebSocket(const FString& URL, const TMap<FString, FString>& UpgradeHeaders);
TSharedPtr<IWebSocket> WebSocket;
EPS_AI_ConvAgent_ConnectionState_ElevenLabs ConnectionState = EPS_AI_ConvAgent_ConnectionState_ElevenLabs::Disconnected;
FPS_AI_ConvAgent_ConversationInfo_ElevenLabs ConversationInfo;

View File

@@ -3,6 +3,7 @@
#include "PS_AI_ConvAgent_ActionSetCustomization_ElevenLabs.h"
#include "PS_AI_ConvAgent_ActionSet_ElevenLabs.h"
#include "PS_AI_ConvAgent.h"
#include "PS_AI_ConvAgent_BlueprintLibrary.h"
#include "DetailLayoutBuilder.h"
#include "DetailCategoryBuilder.h"
@@ -65,14 +66,8 @@ void FPS_AI_ConvAgent_ActionSetCustomization_ElevenLabs::OnUpdateAllAgentsClicke
// ─────────────────────────────────────────────────────────────────────────────
FString FPS_AI_ConvAgent_ActionSetCustomization_ElevenLabs::GetAPIKey() const
{
if (FPS_AI_ConvAgentModule::IsAvailable())
{
if (const UPS_AI_ConvAgent_Settings_ElevenLabs* Settings = FPS_AI_ConvAgentModule::Get().GetSettings())
{
return Settings->API_Key;
}
}
return FString();
// Key is client-provided and stored in a SaveGame slot (see BlueprintLibrary).
return UPS_AI_ConvAgent_BlueprintLibrary::GetElevenLabsAPIKey();
}
UPS_AI_ConvAgent_ActionSet_ElevenLabs* FPS_AI_ConvAgent_ActionSetCustomization_ElevenLabs::GetEditedAsset() const

View File

@@ -4,6 +4,7 @@
#include "PS_AI_ConvAgent_AgentConfig_ElevenLabs.h"
#include "PS_AI_ConvAgent_Tool_ElevenLabs.h"
#include "PS_AI_ConvAgent.h"
#include "PS_AI_ConvAgent_BlueprintLibrary.h"
#include "AssetRegistry/AssetRegistryModule.h"
@@ -1508,14 +1509,8 @@ void FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs::OnFetchAgentClicked()
// ─────────────────────────────────────────────────────────────────────────────
FString FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs::GetAPIKey() const
{
if (FPS_AI_ConvAgentModule::IsAvailable())
{
if (const UPS_AI_ConvAgent_Settings_ElevenLabs* Settings = FPS_AI_ConvAgentModule::Get().GetSettings())
{
return Settings->API_Key;
}
}
return FString();
// Key is client-provided and stored in a SaveGame slot (see BlueprintLibrary).
return UPS_AI_ConvAgent_BlueprintLibrary::GetElevenLabsAPIKey();
}
UPS_AI_ConvAgent_AgentConfig_ElevenLabs* FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs::GetEditedAsset() const
@@ -1827,6 +1822,20 @@ TSharedPtr<FJsonObject> FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs::Bu
}
Root->SetObjectField(TEXT("conversation_config"), ConvConfig);
// platform_settings.auth.enable_auth = true → force every created/updated
// agent to be PRIVATE (authentication required). Hardcoded on purpose (no
// toggle): a public agent can be used by anyone who knows the agent_id,
// running up the owner's ElevenLabs bill without a valid API key.
{
TSharedPtr<FJsonObject> AuthObj = MakeShareable(new FJsonObject());
AuthObj->SetBoolField(TEXT("enable_auth"), true);
TSharedPtr<FJsonObject> PlatformSettings = MakeShareable(new FJsonObject());
PlatformSettings->SetObjectField(TEXT("auth"), AuthObj);
Root->SetObjectField(TEXT("platform_settings"), PlatformSettings);
}
return Root;
}

View File

@@ -5,6 +5,7 @@
#include "PS_AI_ConvAgent_AgentConfig_ElevenLabs.h"
#include "PS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs.h"
#include "PS_AI_ConvAgent.h"
#include "PS_AI_ConvAgent_BlueprintLibrary.h"
#include "DetailLayoutBuilder.h"
#include "DetailCategoryBuilder.h"
@@ -820,15 +821,8 @@ void FPS_AI_ConvAgent_ToolCustomization_ElevenLabs::OnUpdateAllAgentsClicked()
// ─────────────────────────────────────────────────────────────────────────────
FString FPS_AI_ConvAgent_ToolCustomization_ElevenLabs::GetAPIKey() const
{
if (FPS_AI_ConvAgentModule::IsAvailable())
{
if (const UPS_AI_ConvAgent_Settings_ElevenLabs* Settings =
FPS_AI_ConvAgentModule::Get().GetSettings())
{
return Settings->API_Key;
}
}
return FString();
// Key is client-provided and stored in a SaveGame slot (see BlueprintLibrary).
return UPS_AI_ConvAgent_BlueprintLibrary::GetElevenLabsAPIKey();
}
UPS_AI_ConvAgent_Tool_ElevenLabs* FPS_AI_ConvAgent_ToolCustomization_ElevenLabs::GetEditedAsset() const