feat(proxy): add Secure Share for end-to-end encrypted credential links

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Mubashir Osmani 2026-07-10 19:49:04 +00:00
parent b9008cca35
commit e89a991702
21 changed files with 1218 additions and 0 deletions

View file

@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE "LiteLLM_SecureShareTable" (
"share_id" TEXT NOT NULL,
"ciphertext" TEXT NOT NULL,
"salt" TEXT NOT NULL,
"iv" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"created_by" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LiteLLM_SecureShareTable_pkey" PRIMARY KEY ("share_id")
);
-- CreateIndex
CREATE INDEX "LiteLLM_SecureShareTable_expires_at_idx" ON "LiteLLM_SecureShareTable"("expires_at");

View file

@ -1410,3 +1410,15 @@ model LiteLLM_WorkflowMessage {
@@unique([run_id, sequence_number])
@@index([run_id])
}
model LiteLLM_SecureShareTable {
share_id String @id @default(uuid())
ciphertext String
salt String
iv String
expires_at DateTime
created_by String
created_at DateTime @default(now())
@@index([expires_at])
}

View file

@ -358,6 +358,9 @@ from litellm.proxy.management_endpoints.budget_management_endpoints import (
from litellm.proxy.management_endpoints.cache_settings_endpoints import (
router as cache_settings_router,
)
from litellm.proxy.secure_share.secure_share_endpoints import (
router as secure_share_router,
)
from litellm.proxy.management_endpoints.callback_management_endpoints import (
router as callback_management_endpoints_router,
)
@ -15811,6 +15814,7 @@ app.include_router(cost_tracking_settings_router)
app.include_router(router_settings_router)
app.include_router(fallback_management_router)
app.include_router(cache_settings_router)
app.include_router(secure_share_router)
app.include_router(user_agent_analytics_router)
app.include_router(enterprise_router)
app.include_router(ui_discovery_endpoints_router)

View file

@ -1410,3 +1410,15 @@ model LiteLLM_WorkflowMessage {
@@unique([run_id, sequence_number])
@@index([run_id])
}
model LiteLLM_SecureShareTable {
share_id String @id @default(uuid())
ciphertext String
salt String
iv String
expires_at DateTime
created_by String
created_at DateTime @default(now())
@@index([expires_at])
}

View file

View file

@ -0,0 +1,207 @@
"""
SECURE SHARE
Endpoints backing the dashboard "Secure Share" feature: proxy admins share a
credential with an internal user over a temporary, end-to-end-encrypted link.
The browser encrypts the secret with AES-256-GCM under a key derived from an
admin-chosen password (PBKDF2-SHA256). Only the resulting ciphertext and the
crypto parameters needed to decrypt it (PBKDF2 salt, GCM iv) reach the server,
alongside an expiry. The plaintext secret and the password never leave the
client, so a database or server compromise yields ciphertext without the
password required to open it.
POST /secure_share/create - store an encrypted share (proxy admin only)
GET /secure_share/{share_id} - fetch an unexpired share (admins + internal users)
DELETE /secure_share/{share_id} - revoke a share early (proxy admin only)
"""
import base64
import binascii
from datetime import datetime, timedelta, timezone
from enum import Enum
from typing import Final
from fastapi import APIRouter, Depends, HTTPException, Path, status
from pydantic import BaseModel, Field, field_validator
from litellm.proxy._types import (
CommonProxyErrors,
LitellmUserRoles,
UserAPIKeyAuth,
)
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.repositories.table_repositories import SecureShareRepository
router = APIRouter()
MAX_CIPHERTEXT_BYTES: Final[int] = 256 * 1024
_READ_ALLOWED_ROLES: Final[frozenset[str]] = frozenset(
{
LitellmUserRoles.PROXY_ADMIN.value,
LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
LitellmUserRoles.INTERNAL_USER.value,
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value,
}
)
class SecureShareExpiry(str, Enum):
ONE_HOUR = "1h"
SIX_HOURS = "6h"
ONE_DAY = "1d"
SEVEN_DAYS = "7d"
@property
def duration(self) -> timedelta:
return {
SecureShareExpiry.ONE_HOUR: timedelta(hours=1),
SecureShareExpiry.SIX_HOURS: timedelta(hours=6),
SecureShareExpiry.ONE_DAY: timedelta(days=1),
SecureShareExpiry.SEVEN_DAYS: timedelta(days=7),
}[self]
def _validate_b64(value: str, *, field_name: str, max_bytes: int) -> str:
try:
raw = base64.b64decode(value, validate=True)
except (binascii.Error, ValueError) as e:
raise ValueError(f"{field_name} must be base64-encoded") from e
if len(raw) == 0:
raise ValueError(f"{field_name} must not be empty")
if len(raw) > max_bytes:
raise ValueError(f"{field_name} exceeds the {max_bytes}-byte limit")
return value
class SecureShareCreateRequest(BaseModel):
ciphertext: str = Field(description="base64 AES-256-GCM ciphertext of the secret")
salt: str = Field(description="base64 PBKDF2 salt used to derive the AES key")
iv: str = Field(description="base64 AES-GCM initialization vector")
expiry: SecureShareExpiry = Field(description="how long the share stays retrievable")
@field_validator("ciphertext")
@classmethod
def _check_ciphertext(cls, value: str) -> str:
return _validate_b64(value, field_name="ciphertext", max_bytes=MAX_CIPHERTEXT_BYTES)
@field_validator("salt")
@classmethod
def _check_salt(cls, value: str) -> str:
return _validate_b64(value, field_name="salt", max_bytes=64)
@field_validator("iv")
@classmethod
def _check_iv(cls, value: str) -> str:
return _validate_b64(value, field_name="iv", max_bytes=64)
class SecureShareCreateResponse(BaseModel):
share_id: str
expires_at: datetime
class SecureShareGetResponse(BaseModel):
share_id: str
ciphertext: str
salt: str
iv: str
expires_at: datetime
created_by: str
def _secure_share_repository() -> SecureShareRepository:
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise HTTPException(status_code=500, detail={"error": CommonProxyErrors.db_not_connected_error.value})
return SecureShareRepository(prisma_client)
def _require_proxy_admin(user_api_key_dict: UserAPIKeyAuth) -> None:
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value:
raise HTTPException(status_code=403, detail={"error": CommonProxyErrors.not_allowed_access.value})
def _require_read_access(user_api_key_dict: UserAPIKeyAuth) -> None:
if user_api_key_dict.user_role not in _READ_ALLOWED_ROLES:
raise HTTPException(
status_code=403,
detail={"error": "Only proxy admins and internal users can view secure shares."},
)
@router.post(
"/secure_share/create",
tags=["Secure Share"],
dependencies=[Depends(user_api_key_auth)],
response_model=SecureShareCreateResponse,
)
async def create_secure_share(
request: SecureShareCreateRequest,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
) -> SecureShareCreateResponse:
_require_proxy_admin(user_api_key_dict)
from litellm.proxy.proxy_server import litellm_proxy_admin_name
repository = _secure_share_repository()
expires_at = datetime.now(timezone.utc) + request.expiry.duration
created = await repository.table.create(
data={
"ciphertext": request.ciphertext,
"salt": request.salt,
"iv": request.iv,
"expires_at": expires_at,
"created_by": user_api_key_dict.user_id or litellm_proxy_admin_name,
}
)
return SecureShareCreateResponse.model_validate(created, from_attributes=True)
@router.get(
"/secure_share/{share_id}",
tags=["Secure Share"],
dependencies=[Depends(user_api_key_auth)],
response_model=SecureShareGetResponse,
)
async def get_secure_share(
share_id: str = Path(description="id returned by /secure_share/create"),
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
) -> SecureShareGetResponse:
_require_read_access(user_api_key_dict)
repository = _secure_share_repository()
row = await repository.table.find_unique(where={"share_id": share_id})
if row is None:
raise HTTPException(status_code=404, detail={"error": "Secure share not found."})
share = SecureShareGetResponse.model_validate(row, from_attributes=True)
if _is_expired(share.expires_at):
await repository.table.delete(where={"share_id": share_id})
raise HTTPException(status_code=status.HTTP_410_GONE, detail={"error": "Secure share has expired."})
return share
@router.delete(
"/secure_share/{share_id}",
tags=["Secure Share"],
dependencies=[Depends(user_api_key_auth)],
)
async def delete_secure_share(
share_id: str = Path(description="id returned by /secure_share/create"),
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
) -> dict[str, str]:
_require_proxy_admin(user_api_key_dict)
repository = _secure_share_repository()
deleted = await repository.table.delete(where={"share_id": share_id})
if deleted is None:
raise HTTPException(status_code=404, detail={"error": "Secure share not found."})
return {"share_id": share_id, "status": "deleted"}
def _is_expired(expires_at: datetime) -> bool:
reference = expires_at if expires_at.tzinfo is not None else expires_at.replace(tzinfo=timezone.utc)
return reference <= datetime.now(timezone.utc)

View file

@ -46,6 +46,7 @@ from litellm.repositories.table_repositories import (
PrismaTableRepository,
PromptRepository,
SearchToolsRepository,
SecureShareRepository,
SkillsRepository,
SpendLogGuardrailIndexRepository,
SpendLogsRepository,
@ -101,6 +102,7 @@ __all__ = [
"DeletedTeamRepository",
"SkillsRepository",
"CacheConfigRepository",
"SecureShareRepository",
"ManagedVectorStoreIndexRepository",
"WorkflowMessageRepository",
"DailyTagSpendRepository",

View file

@ -161,6 +161,10 @@ class CacheConfigRepository(PrismaTableRepository):
table_name = "litellm_cacheconfig"
class SecureShareRepository(PrismaTableRepository):
table_name = "litellm_securesharetable"
class ManagedVectorStoreIndexRepository(PrismaTableRepository):
table_name = "litellm_managedvectorstoreindextable"

View file

@ -1410,3 +1410,15 @@ model LiteLLM_WorkflowMessage {
@@unique([run_id, sequence_number])
@@index([run_id])
}
model LiteLLM_SecureShareTable {
share_id String @id @default(uuid())
ciphertext String
salt String
iv String
expires_at DateTime
created_by String
created_at DateTime @default(now())
@@index([expires_at])
}

View file

@ -0,0 +1,275 @@
"""
Unit tests for secure share endpoints
"""
import base64
import os
import sys
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
sys.path.insert(0, os.path.abspath("../../../.."))
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.secure_share.secure_share_endpoints import (
SecureShareCreateRequest,
SecureShareExpiry,
create_secure_share,
delete_secure_share,
get_secure_share,
)
def _b64(raw: bytes) -> str:
return base64.b64encode(raw).decode()
def _valid_request(expiry: SecureShareExpiry = SecureShareExpiry.ONE_HOUR) -> SecureShareCreateRequest:
return SecureShareCreateRequest(
ciphertext=_b64(b"encrypted-secret-bytes"),
salt=_b64(b"0123456789abcdef"),
iv=_b64(b"0123456789ab"),
expiry=expiry,
)
def _admin() -> UserAPIKeyAuth:
return UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-admin", user_id="admin-user")
def _fake_table() -> MagicMock:
table = MagicMock()
table.create = AsyncMock()
table.find_unique = AsyncMock()
table.delete = AsyncMock()
return table
def _patched_prisma(table: MagicMock):
prisma_client = MagicMock()
prisma_client.db.litellm_securesharetable = table
return patch.multiple(
"litellm.proxy.proxy_server",
prisma_client=prisma_client,
litellm_proxy_admin_name="default_admin",
)
@pytest.mark.parametrize(
"expiry, expected",
[
(SecureShareExpiry.ONE_HOUR, timedelta(hours=1)),
(SecureShareExpiry.SIX_HOURS, timedelta(hours=6)),
(SecureShareExpiry.ONE_DAY, timedelta(days=1)),
(SecureShareExpiry.SEVEN_DAYS, timedelta(days=7)),
],
)
def test_expiry_durations(expiry: SecureShareExpiry, expected: timedelta):
assert expiry.duration == expected
def test_create_request_rejects_non_base64():
with pytest.raises(ValidationError):
SecureShareCreateRequest(ciphertext="not base64!!", salt=_b64(b"salt"), iv=_b64(b"iv"), expiry="1h")
def test_create_request_rejects_oversized_ciphertext():
with pytest.raises(ValidationError):
SecureShareCreateRequest(
ciphertext=_b64(b"x" * (256 * 1024 + 1)),
salt=_b64(b"salt"),
iv=_b64(b"iv"),
expiry="1h",
)
def test_create_request_rejects_unknown_expiry():
with pytest.raises(ValidationError):
SecureShareCreateRequest(ciphertext=_b64(b"c"), salt=_b64(b"s"), iv=_b64(b"i"), expiry="30d")
@pytest.mark.asyncio
async def test_create_stores_ciphertext_and_computes_expiry():
request = _valid_request(SecureShareExpiry.ONE_DAY)
expires_at = datetime.now(timezone.utc) + timedelta(days=1)
table = _fake_table()
table.create.return_value = SimpleNamespace(share_id="share-123", expires_at=expires_at)
before = datetime.now(timezone.utc)
with _patched_prisma(table):
result = await create_secure_share(request=request, user_api_key_dict=_admin())
after = datetime.now(timezone.utc)
assert result.share_id == "share-123"
stored = table.create.call_args.kwargs["data"]
assert stored["ciphertext"] == request.ciphertext
assert stored["salt"] == request.salt
assert stored["iv"] == request.iv
assert stored["created_by"] == "admin-user"
assert before + timedelta(days=1) <= stored["expires_at"] <= after + timedelta(days=1)
@pytest.mark.asyncio
async def test_create_falls_back_to_admin_name_when_no_user_id():
table = _fake_table()
table.create.return_value = SimpleNamespace(
share_id="s", expires_at=datetime.now(timezone.utc) + timedelta(hours=1)
)
caller = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-admin", user_id=None)
with _patched_prisma(table):
await create_secure_share(request=_valid_request(), user_api_key_dict=caller)
assert table.create.call_args.kwargs["data"]["created_by"] == "default_admin"
@pytest.mark.asyncio
async def test_create_forbidden_for_non_admin():
table = _fake_table()
caller = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, api_key="sk-user", user_id="u1")
with _patched_prisma(table):
with pytest.raises(HTTPException) as exc:
await create_secure_share(request=_valid_request(), user_api_key_dict=caller)
assert exc.value.status_code == 403
table.create.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"role",
[
LitellmUserRoles.PROXY_ADMIN,
LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY,
LitellmUserRoles.INTERNAL_USER,
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY,
],
)
async def test_get_returns_unexpired_share_for_allowed_roles(role: LitellmUserRoles):
expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
table = _fake_table()
table.find_unique.return_value = SimpleNamespace(
share_id="share-1",
ciphertext=_b64(b"c"),
salt=_b64(b"s"),
iv=_b64(b"i"),
expires_at=expires_at,
created_by="admin-user",
)
caller = UserAPIKeyAuth(user_role=role, api_key="sk", user_id="u")
with _patched_prisma(table):
result = await get_secure_share(share_id="share-1", user_api_key_dict=caller)
assert result.share_id == "share-1"
assert result.ciphertext == _b64(b"c")
table.delete.assert_not_called()
@pytest.mark.asyncio
async def test_get_forbidden_for_customer_role():
table = _fake_table()
caller = UserAPIKeyAuth(user_role=LitellmUserRoles.CUSTOMER, api_key="sk", user_id="u")
with _patched_prisma(table):
with pytest.raises(HTTPException) as exc:
await get_secure_share(share_id="share-1", user_api_key_dict=caller)
assert exc.value.status_code == 403
table.find_unique.assert_not_called()
@pytest.mark.asyncio
async def test_get_missing_share_returns_404():
table = _fake_table()
table.find_unique.return_value = None
with _patched_prisma(table):
with pytest.raises(HTTPException) as exc:
await get_secure_share(share_id="missing", user_api_key_dict=_admin())
assert exc.value.status_code == 404
@pytest.mark.asyncio
async def test_get_expired_share_is_deleted_and_returns_410():
expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
table = _fake_table()
table.find_unique.return_value = SimpleNamespace(
share_id="share-old",
ciphertext=_b64(b"c"),
salt=_b64(b"s"),
iv=_b64(b"i"),
expires_at=expires_at,
created_by="admin-user",
)
with _patched_prisma(table):
with pytest.raises(HTTPException) as exc:
await get_secure_share(share_id="share-old", user_api_key_dict=_admin())
assert exc.value.status_code == 410
table.delete.assert_awaited_once_with(where={"share_id": "share-old"})
@pytest.mark.asyncio
async def test_get_treats_naive_expiry_as_utc():
expires_at = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(hours=1)
table = _fake_table()
table.find_unique.return_value = SimpleNamespace(
share_id="share-naive",
ciphertext=_b64(b"c"),
salt=_b64(b"s"),
iv=_b64(b"i"),
expires_at=expires_at,
created_by="admin-user",
)
with _patched_prisma(table):
with pytest.raises(HTTPException) as exc:
await get_secure_share(share_id="share-naive", user_api_key_dict=_admin())
assert exc.value.status_code == 410
@pytest.mark.asyncio
async def test_delete_forbidden_for_non_admin():
table = _fake_table()
caller = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, api_key="sk", user_id="u")
with _patched_prisma(table):
with pytest.raises(HTTPException) as exc:
await delete_secure_share(share_id="share-1", user_api_key_dict=caller)
assert exc.value.status_code == 403
table.delete.assert_not_called()
@pytest.mark.asyncio
async def test_delete_removes_share_for_admin():
table = _fake_table()
table.delete.return_value = SimpleNamespace(share_id="share-1")
with _patched_prisma(table):
result = await delete_secure_share(share_id="share-1", user_api_key_dict=_admin())
assert result == {"share_id": "share-1", "status": "deleted"}
table.delete.assert_awaited_once_with(where={"share_id": "share-1"})
@pytest.mark.asyncio
async def test_delete_missing_share_returns_404():
table = _fake_table()
table.delete.return_value = None
with _patched_prisma(table):
with pytest.raises(HTTPException) as exc:
await delete_secure_share(share_id="missing", user_api_key_dict=_admin())
assert exc.value.status_code == 404

View file

@ -0,0 +1,163 @@
import { CopyOutlined, LockOutlined } from "@ant-design/icons";
import { Button, Card, Input, Select, Typography } from "antd";
import React, { useState } from "react";
import { createSecureShareCall } from "@/components/networking";
import NotificationManager from "@/components/molecules/notifications_manager";
import { encryptSecret } from "./crypto";
const { Title, Text, Paragraph } = Typography;
const EXPIRY_OPTIONS = [
{ value: "1h", label: "1 hour" },
{ value: "6h", label: "6 hours" },
{ value: "1d", label: "1 day" },
{ value: "7d", label: "7 days" },
];
const MIN_PASSWORD_LENGTH = 8;
interface SecureShareCreateProps {
accessToken: string | null;
}
interface CreatedShare {
link: string;
expiresAt: string;
}
function buildShareLink(shareId: string): string {
const base = window.location.href.split(/[?#]/)[0].replace(/\/$/, "");
return `${base}/view?id=${encodeURIComponent(shareId)}`;
}
const SecureShareCreate: React.FC<SecureShareCreateProps> = ({ accessToken }) => {
const [secret, setSecret] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [expiry, setExpiry] = useState("1d");
const [isSubmitting, setIsSubmitting] = useState(false);
const [created, setCreated] = useState<CreatedShare | null>(null);
const reset = () => {
setSecret("");
setPassword("");
setConfirmPassword("");
setExpiry("1d");
setCreated(null);
};
const handleCreate = async () => {
if (!accessToken) {
NotificationManager.error("You must be logged in to create a secure share.");
return;
}
if (secret.trim().length === 0) {
NotificationManager.error("Enter the credential you want to share.");
return;
}
if (password.length < MIN_PASSWORD_LENGTH) {
NotificationManager.error(`Password must be at least ${MIN_PASSWORD_LENGTH} characters.`);
return;
}
if (password !== confirmPassword) {
NotificationManager.error("Passwords do not match.");
return;
}
setIsSubmitting(true);
try {
const encrypted = await encryptSecret(secret, password);
const payload = { ...encrypted, expiry };
const response = await createSecureShareCall(accessToken, payload);
setCreated({ link: buildShareLink(response.share_id), expiresAt: response.expires_at });
NotificationManager.success("Secure share created. The secret was encrypted in your browser.");
} catch (error) {
NotificationManager.fromBackend(error);
} finally {
setIsSubmitting(false);
}
};
const copyLink = async () => {
if (!created) return;
await navigator.clipboard.writeText(created.link);
NotificationManager.success("Link copied to clipboard.");
};
if (created) {
return (
<Card>
<Title level={4}>
<LockOutlined /> Secure share created
</Title>
<Paragraph>
Send this link to the recipient. Share the password separately (not in the same channel). The recipient must
be logged in as a proxy admin or internal user to open it.
</Paragraph>
<div className="flex items-center gap-2">
<Input readOnly value={created.link} />
<Button icon={<CopyOutlined />} onClick={copyLink}>
Copy
</Button>
</div>
<Paragraph className="mt-4">
<Text type="secondary">Expires at {new Date(created.expiresAt).toLocaleString()}</Text>
</Paragraph>
<Button type="primary" onClick={reset}>
Share another
</Button>
</Card>
);
}
return (
<Card>
<Title level={4}>
<LockOutlined /> Secure Share
</Title>
<Paragraph>
Share a credential over a temporary, end-to-end encrypted link. The secret is encrypted in your browser with a
key derived from the password you choose; the server only ever stores ciphertext.
</Paragraph>
<div className="mb-4">
<Text>Credential</Text>
<Input.TextArea
rows={4}
value={secret}
onChange={(e) => setSecret(e.target.value)}
placeholder="Paste the API key or secret to share"
/>
</div>
<div className="mb-4">
<Text>Password</Text>
<Input.Password
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={`At least ${MIN_PASSWORD_LENGTH} characters`}
/>
</div>
<div className="mb-4">
<Text>Confirm password</Text>
<Input.Password
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Re-enter the password"
/>
</div>
<div className="mb-4">
<Text>Expires after</Text>
<Select value={expiry} onChange={setExpiry} options={EXPIRY_OPTIONS} className="w-full" />
</div>
<Button type="primary" loading={isSubmitting} onClick={handleCreate}>
Create secure link
</Button>
</Card>
);
};
export default SecureShareCreate;

View file

@ -0,0 +1,111 @@
import { CopyOutlined, LockOutlined } from "@ant-design/icons";
import { Button, Card, Input, Typography } from "antd";
import { useSearchParams } from "next/navigation";
import React, { useState } from "react";
import { getSecureShareCall } from "@/components/networking";
import NotificationManager from "@/components/molecules/notifications_manager";
import { decryptSecret } from "./crypto";
const { Title, Paragraph, Text } = Typography;
interface SecureShareViewProps {
accessToken: string | null;
}
const SecureShareView: React.FC<SecureShareViewProps> = ({ accessToken }) => {
const searchParams = useSearchParams();
const shareId = searchParams.get("id");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [secret, setSecret] = useState<string | null>(null);
const handleReveal = async () => {
if (!accessToken) {
NotificationManager.error("You must be logged in to view a secure share.");
return;
}
if (!shareId) {
NotificationManager.error("This link is missing its share id.");
return;
}
if (password.length === 0) {
NotificationManager.error("Enter the password you were given.");
return;
}
setIsLoading(true);
try {
const share = await getSecureShareCall(accessToken, shareId);
try {
const plaintext = await decryptSecret(
{ ciphertext: share.ciphertext, salt: share.salt, iv: share.iv },
password,
);
setSecret(plaintext);
} catch {
NotificationManager.error("Wrong password, or this link has been tampered with.");
}
} catch (error) {
NotificationManager.fromBackend(error);
} finally {
setIsLoading(false);
}
};
const copySecret = async () => {
if (secret === null) return;
await navigator.clipboard.writeText(secret);
NotificationManager.success("Secret copied to clipboard.");
};
if (!shareId) {
return (
<Card>
<Title level={4}>Secure Share</Title>
<Paragraph>This link is missing its share id.</Paragraph>
</Card>
);
}
if (secret !== null) {
return (
<Card>
<Title level={4}>
<LockOutlined /> Shared credential
</Title>
<Paragraph>
<Text type="secondary">Decrypted in your browser. Copy it now; the link expires automatically.</Text>
</Paragraph>
<div className="flex items-center gap-2">
<Input.TextArea readOnly rows={4} value={secret} />
<Button icon={<CopyOutlined />} onClick={copySecret}>
Copy
</Button>
</div>
</Card>
);
}
return (
<Card>
<Title level={4}>
<LockOutlined /> Open secure share
</Title>
<Paragraph>Enter the password you received to decrypt this credential in your browser.</Paragraph>
<div className="mb-4">
<Input.Password
value={password}
onChange={(e) => setPassword(e.target.value)}
onPressEnter={handleReveal}
placeholder="Password"
/>
</div>
<Button type="primary" loading={isLoading} onClick={handleReveal}>
Reveal secret
</Button>
</Card>
);
};
export default SecureShareView;

View file

@ -0,0 +1,39 @@
import { webcrypto } from "node:crypto";
import { beforeAll, describe, expect, it } from "vitest";
import { decryptSecret, encryptSecret } from "./crypto";
beforeAll(() => {
if (typeof globalThis.crypto?.subtle === "undefined") {
Object.defineProperty(globalThis, "crypto", { value: webcrypto, configurable: true });
}
});
describe("secure share crypto", () => {
it("round-trips a secret with the correct password", async () => {
const secret = "sk-super-secret-value-123";
const payload = await encryptSecret(secret, "correct horse battery staple");
expect(payload.ciphertext).not.toContain(secret);
await expect(decryptSecret(payload, "correct horse battery staple")).resolves.toBe(secret);
});
it("produces a fresh salt and iv on every call", async () => {
const first = await encryptSecret("value", "password123");
const second = await encryptSecret("value", "password123");
expect(first.salt).not.toBe(second.salt);
expect(first.iv).not.toBe(second.iv);
expect(first.ciphertext).not.toBe(second.ciphertext);
});
it("rejects decryption with the wrong password", async () => {
const payload = await encryptSecret("value", "password123");
await expect(decryptSecret(payload, "wrong-password")).rejects.toThrow();
});
it("rejects decryption when the ciphertext is tampered with", async () => {
const payload = await encryptSecret("value", "password123");
const tampered = { ...payload, ciphertext: btoa("tampered-ciphertext-bytes") };
await expect(decryptSecret(tampered, "password123")).rejects.toThrow();
});
});

View file

@ -0,0 +1,54 @@
const PBKDF2_ITERATIONS = 210_000;
const SALT_BYTES = 16;
const IV_BYTES = 12;
const AES_KEY_BITS = 256;
export interface EncryptedPayload {
ciphertext: string;
salt: string;
iv: string;
}
function toBase64(bytes: Uint8Array<ArrayBuffer>): string {
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
function fromBase64(value: string): Uint8Array<ArrayBuffer> {
const binary = atob(value);
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
}
async function deriveKey(password: string, salt: Uint8Array<ArrayBuffer>): Promise<CryptoKey> {
const keyMaterial = await crypto.subtle.importKey("raw", new TextEncoder().encode(password), "PBKDF2", false, [
"deriveKey",
]);
const kdfParams: Pbkdf2Params = { name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" };
return crypto.subtle.deriveKey(kdfParams, keyMaterial, { name: "AES-GCM", length: AES_KEY_BITS }, false, [
"encrypt",
"decrypt",
]);
}
export async function encryptSecret(plaintext: string, password: string): Promise<EncryptedPayload> {
const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES));
const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
const key = await deriveKey(password, salt);
const cipher = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(plaintext));
return {
ciphertext: toBase64(new Uint8Array(cipher)),
salt: toBase64(salt),
iv: toBase64(iv),
};
}
export async function decryptSecret(payload: EncryptedPayload, password: string): Promise<string> {
const salt = fromBase64(payload.salt);
const iv = fromBase64(payload.iv);
const key = await deriveKey(password, salt);
const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, fromBase64(payload.ciphertext));
return new TextDecoder().decode(plain);
}

View file

@ -0,0 +1,34 @@
"use client";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import LoadingScreen from "@/components/common_components/LoadingScreen";
import { isAdminRole } from "@/utils/roles";
import { Card, Typography } from "antd";
import { Suspense } from "react";
import SecureShareCreate from "./_components/SecureShareCreate";
const { Title, Paragraph } = Typography;
function SecureSharePageContent() {
const { isLoading, isAuthorized, accessToken, userRole } = useAuthorized();
if (isLoading || !isAuthorized) {
return <LoadingScreen />;
}
if (!userRole || !isAdminRole(userRole)) {
return (
<Card>
<Title level={4}>Secure Share</Title>
<Paragraph>Only proxy admins can create secure shares.</Paragraph>
</Card>
);
}
return <SecureShareCreate accessToken={accessToken} />;
}
export default function SecureSharePage() {
return (
<Suspense fallback={<LoadingScreen />}>
<SecureSharePageContent />
</Suspense>
);
}

View file

@ -0,0 +1,22 @@
"use client";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import LoadingScreen from "@/components/common_components/LoadingScreen";
import { Suspense } from "react";
import SecureShareView from "../_components/SecureShareView";
function SecureShareViewPageContent() {
const { isLoading, isAuthorized, accessToken } = useAuthorized();
if (isLoading || !isAuthorized) {
return <LoadingScreen />;
}
return <SecureShareView accessToken={accessToken} />;
}
export default function SecureShareViewPage() {
return (
<Suspense fallback={<LoadingScreen />}>
<SecureShareViewPageContent />
</Suspense>
);
}

View file

@ -20,6 +20,7 @@ import {
FolderOutlined,
KeyOutlined,
LineChartOutlined,
LockOutlined,
PlayCircleOutlined,
RobotOutlined,
SafetyOutlined,
@ -272,6 +273,13 @@ const menuGroups: MenuGroup[] = [
icon: <CreditCardOutlined />,
roles: all_admin_roles,
},
{
key: "secure-share",
page: "secure-share",
label: "Secure Share",
icon: <LockOutlined />,
roles: all_admin_roles,
},
],
},
{

View file

@ -3196,6 +3196,62 @@ export const updateCacheSettingsCall = async (accessToken: string, cacheSettings
}
};
export interface SecureShareCreatePayload {
ciphertext: string;
salt: string;
iv: string;
expiry: string;
}
export interface SecureShareCreateResponse {
share_id: string;
expires_at: string;
}
export interface SecureShareGetResponse {
share_id: string;
ciphertext: string;
salt: string;
iv: string;
expires_at: string;
created_by: string;
}
export const createSecureShareCall = async (
accessToken: string,
payload: SecureShareCreatePayload,
): Promise<SecureShareCreateResponse> => {
try {
return await apiClient.post<SecureShareCreateResponse>(`/secure_share/create`, {
accessToken,
body: payload,
});
} catch (error) {
console.error("Failed to create secure share:", error);
throw error;
}
};
export const getSecureShareCall = async (accessToken: string, shareId: string): Promise<SecureShareGetResponse> => {
try {
return await apiClient.get<SecureShareGetResponse>(`/secure_share/${encodeURIComponent(shareId)}`, {
accessToken,
});
} catch (error) {
console.error("Failed to fetch secure share:", error);
throw error;
}
};
export const deleteSecureShareCall = async (accessToken: string, shareId: string) => {
try {
return await apiClient.delete(`/secure_share/${encodeURIComponent(shareId)}`, { accessToken });
} catch (error) {
console.error("Failed to delete secure share:", error);
throw error;
}
};
export const getPassThroughEndpointsCall = async (accessToken: string, teamId?: string | null) => {
try {
let path = `/config/pass_through_endpoint`;

View file

@ -12197,6 +12197,41 @@ export interface paths {
patch?: never;
trace?: never;
};
"/secure_share/create": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Create Secure Share */
post: operations["create_secure_share_secure_share_create_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/secure_share/{share_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get Secure Share */
get: operations["get_secure_share_secure_share__share_id__get"];
put?: never;
post?: never;
/** Delete Secure Share */
delete: operations["delete_secure_share_secure_share__share_id__delete"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/settings": {
parameters: {
query?: never;
@ -30536,6 +30571,59 @@ export interface components {
/** Timeout */
timeout?: number | null;
};
/** SecureShareCreateRequest */
SecureShareCreateRequest: {
/**
* Ciphertext
* @description base64 AES-256-GCM ciphertext of the secret
*/
ciphertext: string;
/** @description how long the share stays retrievable */
expiry: components["schemas"]["SecureShareExpiry"];
/**
* Iv
* @description base64 AES-GCM initialization vector
*/
iv: string;
/**
* Salt
* @description base64 PBKDF2 salt used to derive the AES key
*/
salt: string;
};
/** SecureShareCreateResponse */
SecureShareCreateResponse: {
/**
* Expires At
* Format: date-time
*/
expires_at: string;
/** Share Id */
share_id: string;
};
/**
* SecureShareExpiry
* @enum {string}
*/
SecureShareExpiry: "1h" | "6h" | "1d" | "7d";
/** SecureShareGetResponse */
SecureShareGetResponse: {
/** Ciphertext */
ciphertext: string;
/** Created By */
created_by: string;
/**
* Expires At
* Format: date-time
*/
expires_at: string;
/** Iv */
iv: string;
/** Salt */
salt: string;
/** Share Id */
share_id: string;
};
/**
* Skill
* @description Represents a skill from the Anthropic Skills API
@ -48467,6 +48555,105 @@ export interface operations {
};
};
};
create_secure_share_secure_share_create_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SecureShareCreateRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SecureShareCreateResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_secure_share_secure_share__share_id__get: {
parameters: {
query?: never;
header?: never;
path: {
/** @description id returned by /secure_share/create */
share_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SecureShareGetResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
delete_secure_share_secure_share__share_id__delete: {
parameters: {
query?: never;
header?: never;
path: {
/** @description id returned by /secure_share/create */
share_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: string;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
active_callbacks_settings_get: {
parameters: {
query?: never;

View file

@ -49,6 +49,7 @@ export const MIGRATED_PAGES: Record<string, string> = {
users: "users",
teams: "teams",
organizations: "organizations",
"secure-share": "secure-share",
};
function uiBase(): string {