first commit
This commit is contained in:
+72
@@ -0,0 +1,72 @@
|
||||
/target
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
*.py[cod]
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
.venv/
|
||||
env/
|
||||
bin/
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
include/
|
||||
man/
|
||||
venv/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
pip-selfcheck.json
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.coverage
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
|
||||
# Mr Developer
|
||||
.mr.developer.cfg
|
||||
.project
|
||||
.pydevproject
|
||||
|
||||
# Rope
|
||||
.ropeproject
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
*.pot
|
||||
|
||||
.DS_Store
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyCharm
|
||||
.idea/
|
||||
|
||||
# VSCode
|
||||
.vscode/
|
||||
|
||||
# Pyenv
|
||||
.python-version
|
||||
@@ -0,0 +1,21 @@
|
||||
[build-system]
|
||||
build-backend = "hatchling.build"
|
||||
requires = ["hatchling"]
|
||||
|
||||
[project]
|
||||
name = "bytedance"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"requests",
|
||||
"pyjwt",
|
||||
"pymerkle",
|
||||
"jwcrypto",
|
||||
"loguru",
|
||||
"wget"
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/bytedance"]
|
||||
@@ -0,0 +1,4 @@
|
||||
from pkgutil import extend_path
|
||||
|
||||
# Make "bytedance" a namespace package
|
||||
__path__ = extend_path(__path__, __name__)
|
||||
@@ -0,0 +1,28 @@
|
||||
__all__ = [
|
||||
"Client",
|
||||
"ClientConfig",
|
||||
"FileMode",
|
||||
"RaRequest",
|
||||
"RaResponse",
|
||||
"ResponseKey",
|
||||
"Server",
|
||||
"ServerConfig",
|
||||
"TksAppOptions",
|
||||
"__version__",
|
||||
]
|
||||
|
||||
__version__ = "0.1.7.78"
|
||||
|
||||
from .client import Client
|
||||
from .config import ClientConfig, ServerConfig
|
||||
from .crypto import FileMode, ResponseKey
|
||||
from .ra import RaRequest, RaResponse
|
||||
# from .server import Server
|
||||
from .service import TksAppOptions
|
||||
|
||||
|
||||
def __getattr__(name: str) -> object:
|
||||
if name == "Server":
|
||||
from .server import Server
|
||||
return Server
|
||||
raise AttributeError(f"module {__name__} has no attribute {name}")
|
||||
@@ -0,0 +1,300 @@
|
||||
"""
|
||||
数据发送方接口.
|
||||
"""
|
||||
|
||||
__all__ = ["Client"]
|
||||
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from typing_extensions import TYPE_CHECKING, Dict, Optional, Tuple, Union
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from _typeshed import GenericPath
|
||||
else:
|
||||
GenericPath = str
|
||||
|
||||
from . import error
|
||||
from .config import ClientConfig
|
||||
from .crypto import ClientSessionKey, FileMode, PrivateKey, ResponseKey, AesKey
|
||||
from .log import LogConfig, logger
|
||||
from .ra import attest_server, generate_nonce
|
||||
from .utils import RepeatTimer
|
||||
|
||||
|
||||
@dataclass(eq=False)
|
||||
class Client:
|
||||
"""
|
||||
数据发送方接口.
|
||||
|
||||
本类的所有方法是线程安全的.
|
||||
"""
|
||||
|
||||
config: ClientConfig
|
||||
|
||||
session_key: Optional[ClientSessionKey] = field(init=False, default=None)
|
||||
|
||||
attest_timer: Optional[RepeatTimer] = field(init=False, default=None)
|
||||
|
||||
root_key_info: Dict[str, PrivateKey] = field(init=False, default_factory=dict)
|
||||
|
||||
last_ra_time: float = 0.0
|
||||
|
||||
def __post_init__(self):
|
||||
try:
|
||||
self._init_log()
|
||||
|
||||
if self.config.root_key_config:
|
||||
self._root_key_parse()
|
||||
|
||||
# if self.config.attest_interval is not None:
|
||||
# # 初始化客户端时,以当前时间作为最近活跃时间
|
||||
# self.last_active_time = time.time()
|
||||
# # 内置最小定时间隔配置,避免因用户的不当配置造成的高负荷
|
||||
# self.attest_timer = timer = weak_repeat_timer(
|
||||
# max(self.config.attest_interval, ClientConst.MIN_ATTEST_INTERVAL),
|
||||
# self._attest_server_no_raise,
|
||||
# )
|
||||
# timer.start()
|
||||
|
||||
self._attest_server_no_raise()
|
||||
except Exception as e:
|
||||
logger.critical(f"__post_init__ failed, e={e}")
|
||||
|
||||
def _attest_server_no_raise(self):
|
||||
logger.info("attest_server active")
|
||||
try:
|
||||
if not self.attest_server(None):
|
||||
logger.error("ra attest failed, Please check your config !")
|
||||
else:
|
||||
self.last_ra_time = time.time()
|
||||
logger.info("attest_server success")
|
||||
except Exception as e:
|
||||
logger.critical(f"Failed to attest server, {e}")
|
||||
|
||||
def attest_server(self, token: Optional[str] = None) -> bool:
|
||||
"""
|
||||
对数据接收方进行远程证明.
|
||||
"""
|
||||
if self.config.pub_key_path:
|
||||
with open(self.config.pub_key_path) as f:
|
||||
public_pem = f.read()
|
||||
logger.debug(f"read pub key success, pub_key={public_pem}")
|
||||
self.session_key = ClientSessionKey.load(public_pem)
|
||||
return True
|
||||
else:
|
||||
max_try_count = 3
|
||||
count = 0
|
||||
|
||||
nonce = generate_nonce()
|
||||
while count < max_try_count:
|
||||
count += 1
|
||||
ret, pub_key = attest_server(token, self.config, nonce)
|
||||
if ret:
|
||||
logger.info("ra attest success")
|
||||
if pub_key:
|
||||
logger.debug(f"get pub key success, pub_key={pub_key}")
|
||||
self.session_key = ClientSessionKey.load(pub_key)
|
||||
return True
|
||||
else:
|
||||
if pub_key and not self.config.attest_must:
|
||||
logger.warning(f"ra attest failed, but get pub key success, pub_key={pub_key}")
|
||||
self.session_key = ClientSessionKey.load(pub_key)
|
||||
return True
|
||||
else:
|
||||
time.sleep(2)
|
||||
logger.info("ra attest failed, try again...")
|
||||
|
||||
return False
|
||||
|
||||
def _get_key(self) -> ClientSessionKey:
|
||||
# 线程安全: 将 session_key 字段保存为本地变量, 避免访问两次
|
||||
key = self.session_key
|
||||
if key is None:
|
||||
raise error.KeyMissingError()
|
||||
return key
|
||||
|
||||
def encrypt(self, plaintext: Union[str, bytes]) -> str:
|
||||
"""
|
||||
加密字符串或二进制数据.
|
||||
|
||||
Returns:
|
||||
信封加密数据.
|
||||
|
||||
Raises:
|
||||
KeyMissingError: 没有可用的密钥. 应通过 `attest_server` 验证数据接收方, 并获取密钥.
|
||||
"""
|
||||
message, _ = self.encrypt_with_response(plaintext)
|
||||
return message
|
||||
|
||||
def encrypt_with_response(
|
||||
self, plaintext: Union[str, bytes], aes_key: AesKey = None
|
||||
) -> Tuple[str, ResponseKey]:
|
||||
"""
|
||||
加密字符串或二进制数据, 并获取用于解密响应的密钥.
|
||||
|
||||
Returns:
|
||||
信封加密数据, 及用于解密响应的密钥.
|
||||
|
||||
Raises:
|
||||
KeyMissingError: 没有可用的密钥. 应通过 `attest_server` 验证数据接收方, 并获取密钥.
|
||||
"""
|
||||
try:
|
||||
if self.config.attest_interval is not None \
|
||||
and time.time() - self.last_ra_time > self.config.attest_interval:
|
||||
self._attest_server_no_raise()
|
||||
|
||||
message, response_key = self._get_key().encrypt_with_response(plaintext, aes_key)
|
||||
return message.serialize(), response_key
|
||||
except error.KeyMissingError as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
raise error.EncryptionError(
|
||||
"Encrypt failed, Please check your config"
|
||||
) from e
|
||||
|
||||
def encrypt_file(
|
||||
self,
|
||||
source_path: GenericPath,
|
||||
dest_path: GenericPath,
|
||||
mode: FileMode,
|
||||
) -> str:
|
||||
"""
|
||||
加密文件数据.
|
||||
|
||||
Returns:
|
||||
信封加密元数据. 可伴随加密文件发送给数据接收方.
|
||||
"""
|
||||
metadata, _ = self.encrypt_file_with_response(source_path, dest_path, mode)
|
||||
return metadata
|
||||
|
||||
def encrypt_file_with_response(
|
||||
self,
|
||||
source_path: GenericPath,
|
||||
dest_path: GenericPath,
|
||||
mode: FileMode,
|
||||
) -> Tuple[str, ResponseKey]:
|
||||
"""
|
||||
加密文件数据, 并获取用于解密响应的密钥.
|
||||
|
||||
Returns:
|
||||
信封加密元数据, 及用于解密响应的密钥. 其中元数据可伴随加密文件发送给数据接收方.
|
||||
"""
|
||||
if self.config.attest_interval is not None and time.time() - self.last_ra_time > self.config.attest_interval:
|
||||
self._attest_server_no_raise()
|
||||
|
||||
if mode not in ["b", "t"]:
|
||||
raise error.ParamError("mode", "Mode should be 'b' or 't'")
|
||||
|
||||
try:
|
||||
key = self._get_key()
|
||||
with open(source_path, f"r{mode}") as source, open(
|
||||
dest_path, f"w{mode}"
|
||||
) as dest:
|
||||
metadata, response_key = key.encrypt_stream_with_response(
|
||||
source, dest, mode
|
||||
)
|
||||
|
||||
return metadata.serialize(), response_key
|
||||
except error.KeyMissingError as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
raise error.EncryptionError(
|
||||
"Encrypt file failed, Please check your config"
|
||||
) from e
|
||||
|
||||
def _root_key_parse(self):
|
||||
try:
|
||||
root_key_config = self.config.root_key_config
|
||||
if not root_key_config:
|
||||
return
|
||||
|
||||
if isinstance(root_key_config, str):
|
||||
root_key_config = json.loads(root_key_config)
|
||||
|
||||
for app_info, pri_key_path in root_key_config.items():
|
||||
with open(pri_key_path) as f:
|
||||
private_pem = f.read()
|
||||
self.root_key_info[app_info] = PrivateKey.from_private_pem(private_pem)
|
||||
|
||||
logger.info("root_key_parse success")
|
||||
except Exception as e:
|
||||
logger.critical("root_key_parse exception={}".format(e))
|
||||
raise error.ConfigError("RootKey conf", "RootPriKey conf error")
|
||||
|
||||
def gen_sign(self, app_info: str, msg: Union[str, bytes]) -> str:
|
||||
"""
|
||||
生成签名
|
||||
:param app_info: app_id+app_name
|
||||
:param msg: app_id+app_name
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
if not app_info or not msg:
|
||||
logger.error("gen_sign error, app_info is NULL")
|
||||
raise error.SignatureError("RSA signing failed") from None
|
||||
|
||||
if app_info not in self.root_key_info:
|
||||
logger.info(
|
||||
f"gen_sign error, can not find app_info in root key conf, app_info={app_info}"
|
||||
)
|
||||
raise error.SignatureError(
|
||||
f"can not find app_info={app_info}"
|
||||
) from None
|
||||
|
||||
return base64.b64encode(self.root_key_info[app_info].sign(msg)).decode()
|
||||
|
||||
except Exception as e:
|
||||
logger.critical("gen_sign exception={}".format(e))
|
||||
raise error.SignatureError("can not find app_info") from e
|
||||
|
||||
# def dynamic_update_interval(self):
|
||||
# """
|
||||
# 动态更新定时逻辑的时间间隔。对不活跃的客户端,增大其定时逻辑的时间间隔
|
||||
# """
|
||||
# try:
|
||||
# # 对于非定时RA的客户端,不做动态更新
|
||||
# if self.config.attest_interval is None or self.attest_timer is None:
|
||||
# return
|
||||
#
|
||||
# # 如果超过一定时间没有活跃,就增大定时逻辑的时间间隔
|
||||
# if time.time() - self.last_active_time > ClientConst.INACTIVE_THRESHOLD:
|
||||
# self.attest_timer.interval = max(
|
||||
# self.config.attest_interval, ClientConst.INACTIVE_ATTEST_INTERVAL
|
||||
# )
|
||||
# else:
|
||||
# # 如果活跃时间在阈值内,就恢复初始的时间间隔
|
||||
# self.attest_timer.interval = max(
|
||||
# self.config.attest_interval, ClientConst.MIN_ATTEST_INTERVAL
|
||||
# )
|
||||
# except Exception as e:
|
||||
# logger.error("dynamic_update_interval exception={}".format(e))
|
||||
|
||||
def _init_log(self):
|
||||
config = LogConfig()
|
||||
|
||||
log_config: Dict[str, Any] = {}
|
||||
if self.config.log_config:
|
||||
log_config = json.loads(self.config.log_config)
|
||||
|
||||
if dir := log_config.get("dir"):
|
||||
config.dir = dir
|
||||
|
||||
if filename := log_config.get("filename"):
|
||||
config.filename = filename
|
||||
# else:
|
||||
# config.filename = "client.log"
|
||||
|
||||
if rotation := log_config.get("rotation"):
|
||||
config.rotation = rotation
|
||||
|
||||
if retention := log_config.get("retention"):
|
||||
config.retention = retention
|
||||
|
||||
if level := log_config.get("level"):
|
||||
config.level = level
|
||||
|
||||
logger.init_log_config(config)
|
||||
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
安全通信配置.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
|
||||
from typing_extensions import TYPE_CHECKING, Optional, Self
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from _typeshed import GenericPath
|
||||
else:
|
||||
GenericPath = str
|
||||
|
||||
from .ra import RaConfig
|
||||
from .service import EpsConfig, RasConfig, TksConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClientConfig(RaConfig):
|
||||
"""
|
||||
安全通信数据发送方配置.
|
||||
"""
|
||||
|
||||
attest_interval: Optional[float] = 3600
|
||||
"""
|
||||
自动对数据接收方进行远程证明的时间间隔 (秒).
|
||||
为空表示不自动进行远程证明 (默认).
|
||||
"""
|
||||
|
||||
root_key_config: Optional[str] = None
|
||||
"""
|
||||
root_key的配置,json格式的字符串.
|
||||
"""
|
||||
|
||||
log_config: Optional[str] = None
|
||||
|
||||
pub_key_path: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: GenericPath) -> Self:
|
||||
"""从文件读取配置对象."""
|
||||
|
||||
with open(path) as f:
|
||||
return cls.from_dict(json.load(f))
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, obj: dict) -> Self:
|
||||
"""从 dict 对象创建配置对象."""
|
||||
|
||||
obj.pop("$schema", None)
|
||||
|
||||
return cls(**obj)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls, env_key: str) -> Self:
|
||||
"""从环境变量读取配置对象."""
|
||||
|
||||
config = os.getenv(env_key, "{}")
|
||||
return cls.from_dict(json.loads(config))
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServerConfig(EpsConfig, RasConfig, TksConfig):
|
||||
"""
|
||||
安全通信数据接收方配置.
|
||||
"""
|
||||
|
||||
tks_app_id: Optional[str] = None
|
||||
"""
|
||||
TKS app id.
|
||||
"""
|
||||
|
||||
tks_ring_id: Optional[str] = None
|
||||
"""
|
||||
TKS 密钥环 id.
|
||||
为空表示不访问 TKS 获取密钥 (默认).
|
||||
"""
|
||||
|
||||
tks_key_id: Optional[str] = None
|
||||
"""
|
||||
TKS 密钥 id.
|
||||
为空表示不访问 TKS 获取密钥 (默认).
|
||||
"""
|
||||
|
||||
key_file: Optional[GenericPath] = None
|
||||
"""
|
||||
密钥文件路径.
|
||||
为空表示不从文件读取密钥 (默认).
|
||||
"""
|
||||
|
||||
refresh_interval: Optional[float] = None
|
||||
"""
|
||||
自动从 TKS 或者文件获取密钥的时间间隔 (秒).
|
||||
为空表示不自动获取 (默认).
|
||||
"""
|
||||
|
||||
valid_time: float = timedelta(minutes=30).total_seconds()
|
||||
"""【未启用】密钥的有效期 (秒). 默认为 30 分钟. """
|
||||
|
||||
client_ra_url: Optional[str] = None
|
||||
"""
|
||||
客户端 RA 接口地址.
|
||||
为空表示不需要对客户端做 RA (默认).
|
||||
"""
|
||||
|
||||
pcc_login_url: Optional[str] = None
|
||||
pcc_app_id: Optional[str] = None
|
||||
pcc_password: Optional[str] = None
|
||||
"""
|
||||
pcc_controller模块的登录信息.
|
||||
为空表示不需要做.
|
||||
"""
|
||||
|
||||
bytedance_pcc_info: str = ""
|
||||
"""访问bytedance pcc_controller 网关的信息."""
|
||||
|
||||
bytedance_top_info: str = ""
|
||||
"""访问bytedance TOP网关的信息."""
|
||||
|
||||
root_key_config: Optional[str] = None
|
||||
"""
|
||||
root_key的配置,json格式的字符串.
|
||||
"""
|
||||
|
||||
client_ra_config: Optional[str] = None
|
||||
"""
|
||||
客户端RA的配置,json格式的字符串.
|
||||
"""
|
||||
|
||||
log_config: Optional[str] = None
|
||||
|
||||
need_evidence: bool = True
|
||||
|
||||
attest_gpu: bool = True
|
||||
|
||||
key_type: str = "tks"
|
||||
|
||||
service_id: str = ""
|
||||
|
||||
tks_policy_id: str = ""
|
||||
|
||||
tks_url: str = ""
|
||||
|
||||
tks_service_name: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: GenericPath) -> Self:
|
||||
"""从文件读取配置对象."""
|
||||
|
||||
with open(path) as f:
|
||||
return cls.from_dict(json.load(f))
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, obj: dict) -> Self:
|
||||
"""从 dict 对象创建配置对象."""
|
||||
|
||||
obj.pop("$schema", None)
|
||||
|
||||
return cls(**obj)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls, env_key: str) -> Self:
|
||||
"""从env中读取配置对象."""
|
||||
|
||||
conf_value = os.getenv(env_key, "{}")
|
||||
if isinstance(conf_value, str):
|
||||
conf_value = json.loads(conf_value)
|
||||
return cls.from_dict(conf_value)
|
||||
@@ -0,0 +1,5 @@
|
||||
class ClientConst:
|
||||
MIN_ATTEST_INTERVAL = 3600 # 内置最小的attest间隔,单位秒
|
||||
INACTIVE_ATTEST_INTERVAL = 3600 * 3 # 非活跃客户端的attest时间间隔,单位秒
|
||||
|
||||
INACTIVE_THRESHOLD = 3600 # 超过一定阈值被认为是不活跃的客户端,单位秒
|
||||
@@ -0,0 +1,14 @@
|
||||
__all__ = [
|
||||
"AesKey",
|
||||
"ClientSessionKey",
|
||||
"EncryptedMessage",
|
||||
"FileMode",
|
||||
"PrivateKey",
|
||||
"PublicKey",
|
||||
"ResponseKey",
|
||||
"ServerSessionKey",
|
||||
]
|
||||
|
||||
from .aes import AesKey, FileMode
|
||||
from .envelope import ClientSessionKey, EncryptedMessage, ResponseKey, ServerSessionKey
|
||||
from .rsa import PrivateKey, PublicKey
|
||||
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
AES 加密算法.
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
"AES_KEY_LEN",
|
||||
"AES_MAC_LEN",
|
||||
"AES_NONCE_LEN",
|
||||
"AesKey",
|
||||
"FileMode",
|
||||
]
|
||||
|
||||
import base64
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from io import BytesIO
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from typing_extensions import IO, Literal, Optional, Self, Tuple
|
||||
|
||||
from .. import error
|
||||
|
||||
AES_KEY_LEN = 32
|
||||
|
||||
AES_NONCE_LEN = 12
|
||||
|
||||
AES_MAC_LEN = 16
|
||||
|
||||
CHUNK_SIZE = 1024
|
||||
|
||||
FileMode = Literal["b", "t"]
|
||||
"""
|
||||
文件加密模式: b 表示二进制, t 表示文本 (按行加密, 加密数据 Base64 编码后按行保存).
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(eq=False, frozen=True)
|
||||
class AesKey:
|
||||
"""
|
||||
AES 密钥.
|
||||
|
||||
本类不含可变状态, 所有方法是线程安全的.
|
||||
"""
|
||||
|
||||
key: bytes
|
||||
|
||||
def __post_init__(self):
|
||||
if len(self.key) != AES_KEY_LEN:
|
||||
raise error.ParamError("key", "Invalid AES key length")
|
||||
|
||||
@classmethod
|
||||
def generate(cls) -> Self:
|
||||
"""
|
||||
生成 AES 密钥.
|
||||
"""
|
||||
key = secrets.token_bytes(AES_KEY_LEN)
|
||||
|
||||
return cls(key)
|
||||
|
||||
def encrypt(
|
||||
self,
|
||||
plaintext: bytes,
|
||||
nonce: Optional[bytes] = None,
|
||||
) -> Tuple[bytes, bytes, bytes]:
|
||||
"""
|
||||
用 AES-GCM 加密数据.
|
||||
|
||||
Returns:
|
||||
单次随机数, 密文, 及校验码.
|
||||
|
||||
Raises:
|
||||
EncryptionError:
|
||||
"""
|
||||
with BytesIO(plaintext) as source, BytesIO() as dest:
|
||||
nonce, mac = self.encrypt_stream(source, dest, "b", nonce)
|
||||
ciphertext = dest.getvalue()
|
||||
|
||||
return nonce, ciphertext, mac
|
||||
|
||||
def encrypt_stream(
|
||||
self,
|
||||
source: IO,
|
||||
dest: IO,
|
||||
mode: FileMode,
|
||||
nonce: Optional[bytes] = None,
|
||||
) -> Tuple[bytes, bytes]:
|
||||
"""
|
||||
用 AES-GCM 加密数据流.
|
||||
|
||||
Returns:
|
||||
单次随机数及校验码.
|
||||
|
||||
Raises:
|
||||
EncryptionError:
|
||||
"""
|
||||
if nonce is None:
|
||||
nonce = secrets.token_bytes(AES_NONCE_LEN)
|
||||
elif len(nonce) != AES_NONCE_LEN:
|
||||
raise error.ParamError("nonce", "wrong length")
|
||||
|
||||
try:
|
||||
aes = Cipher(algorithms.AES(self.key), modes.GCM(nonce)).encryptor()
|
||||
|
||||
if mode == "b":
|
||||
source_b: IO[bytes] = source
|
||||
dest_b: IO[bytes] = dest
|
||||
while plaintext := source_b.read(CHUNK_SIZE):
|
||||
ciphertext = aes.update(plaintext)
|
||||
dest_b.write(ciphertext)
|
||||
else:
|
||||
source_t: IO[str] = source
|
||||
dest_t: IO[str] = dest
|
||||
for plaintext in source_t:
|
||||
plaintext = plaintext.rstrip("\n")
|
||||
ciphertext = aes.update(plaintext.encode())
|
||||
dest_t.write(base64.b64encode(ciphertext).decode())
|
||||
dest_t.write("\n")
|
||||
|
||||
ciphertext = aes.finalize()
|
||||
mac = aes.tag
|
||||
except Exception as e:
|
||||
raise error.EncryptionError("AES encryption failed") from e
|
||||
|
||||
return nonce, mac
|
||||
|
||||
def decrypt(self, nonce: bytes, ciphertext: bytes, mac: Optional[bytes]) -> bytes:
|
||||
"""
|
||||
用 AES-GCM 解密并校验数据.
|
||||
|
||||
Raises:
|
||||
DecryptionError:
|
||||
"""
|
||||
with BytesIO(ciphertext) as source, BytesIO() as dest:
|
||||
self.decrypt_stream(source, dest, nonce, mac, "b")
|
||||
plaintext = dest.getvalue()
|
||||
|
||||
return plaintext
|
||||
|
||||
def decrypt_stream(
|
||||
self,
|
||||
source: IO,
|
||||
dest: IO,
|
||||
nonce: bytes,
|
||||
mac: Optional[bytes],
|
||||
mode: FileMode,
|
||||
) -> None:
|
||||
"""
|
||||
用 AES-GCM 解密并校验数据流.
|
||||
|
||||
Raises:
|
||||
DecryptionError:
|
||||
"""
|
||||
try:
|
||||
aes = Cipher(algorithms.AES(self.key), modes.GCM(nonce)).decryptor()
|
||||
|
||||
if mode == "b":
|
||||
source_b: IO[bytes] = source
|
||||
dest_b: IO[bytes] = dest
|
||||
while ciphertext := source_b.read(CHUNK_SIZE):
|
||||
plaintext = aes.update(ciphertext)
|
||||
dest_b.write(plaintext)
|
||||
else:
|
||||
source_t: IO[str] = source
|
||||
dest_t: IO[str] = dest
|
||||
for ciphertext in source_t:
|
||||
ciphertext = ciphertext.rstrip("\n")
|
||||
plaintext = aes.update(base64.b64decode(ciphertext)).decode()
|
||||
dest_t.write(plaintext)
|
||||
dest_t.write("\n")
|
||||
|
||||
if mac is not None:
|
||||
aes.finalize_with_tag(mac)
|
||||
except Exception as e:
|
||||
raise error.DecryptionError("AES decryption failed") from e
|
||||
@@ -0,0 +1,395 @@
|
||||
"""
|
||||
信封加密算法.
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
"ClientSessionKey",
|
||||
"EncryptedMessage",
|
||||
"ResponseKey",
|
||||
"ServerSessionKey",
|
||||
]
|
||||
|
||||
import base64
|
||||
import dataclasses
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from io import BytesIO
|
||||
from threading import Lock
|
||||
|
||||
from typing_extensions import IO, TYPE_CHECKING, Optional, Self, Set, Tuple, Union
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from _typeshed import GenericPath
|
||||
else:
|
||||
GenericPath = str
|
||||
|
||||
|
||||
from .. import error
|
||||
from .aes import AES_KEY_LEN, AES_MAC_LEN, AES_NONCE_LEN, AesKey, FileMode
|
||||
from .rsa import PrivateKey, PublicKey
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(eq=False, frozen=True)
|
||||
class EncryptedMessage:
|
||||
"""
|
||||
信封加密数据.
|
||||
可作为数据发送方的请求, 及数据接收方的响应.
|
||||
|
||||
本类不含可变状态, 所有方法是线程安全的.
|
||||
"""
|
||||
|
||||
nonce: bytes
|
||||
"""单次随机数."""
|
||||
|
||||
mac: bytes
|
||||
"""校验码."""
|
||||
|
||||
key: Optional[bytes] = None
|
||||
"""
|
||||
用公钥加密的对称密钥.
|
||||
在数据发送方的加密请求中使用, 在数据接收方的加密响应中为空.
|
||||
"""
|
||||
|
||||
ciphertext: Optional[bytes] = None
|
||||
"""
|
||||
加密数据.
|
||||
在字符串 / 二进制数据加密中使用, 在文件加密中为空.
|
||||
"""
|
||||
|
||||
def serialize(self) -> str:
|
||||
"""
|
||||
将消息序列化为字符串.
|
||||
"""
|
||||
return json.dumps(
|
||||
{
|
||||
k: base64.b64encode(v).decode()
|
||||
for k, v in self.__dict__.items()
|
||||
if v is not None
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def deserialize(cls, data: Union[str, bytes]) -> Self:
|
||||
"""
|
||||
从字符串反序列化消息.
|
||||
"""
|
||||
try:
|
||||
return cls(**{k: base64.b64decode(v) for k, v in json.loads(data).items()})
|
||||
except Exception:
|
||||
raise error.DecryptionError("Invalid encoding") from None
|
||||
|
||||
|
||||
@dataclass(eq=False, frozen=True)
|
||||
class ResponseKey:
|
||||
"""
|
||||
对响应加解密的密钥.
|
||||
|
||||
本类不含可变状态, 所有方法是线程安全的.
|
||||
"""
|
||||
|
||||
_key: AesKey
|
||||
|
||||
def encrypt(self, plaintext: Union[str, bytes]) -> str:
|
||||
"""
|
||||
加密响应 (由服务端使用).
|
||||
"""
|
||||
if isinstance(plaintext, str):
|
||||
plaintext = plaintext.encode("utf-8")
|
||||
|
||||
nonce, ciphertext, mac = self._key.encrypt(plaintext)
|
||||
return EncryptedMessage(nonce=nonce, mac=mac, ciphertext=ciphertext).serialize()
|
||||
|
||||
def decrypt(self, response: Union[str, bytes]) -> bytes:
|
||||
"""
|
||||
解密响应 (由客户端使用).
|
||||
"""
|
||||
message = EncryptedMessage.deserialize(response)
|
||||
|
||||
if message.ciphertext is None or message.key is not None:
|
||||
raise error.ParamError("response", "Message is not server response")
|
||||
if len(message.nonce) != AES_NONCE_LEN or len(message.mac) != AES_MAC_LEN:
|
||||
raise error.DecryptionError("Invalid data length")
|
||||
|
||||
return self._key.decrypt(message.nonce, message.ciphertext, message.mac)
|
||||
|
||||
def encrypt_file(
|
||||
self,
|
||||
source_path: GenericPath,
|
||||
dest_path: GenericPath,
|
||||
mode: FileMode,
|
||||
nonce: Optional[bytes] = None,
|
||||
) -> str:
|
||||
"""
|
||||
加密响应文件 (由服务端使用).
|
||||
"""
|
||||
with open(source_path, f"r{mode}") as source, open(
|
||||
dest_path, f"w{mode}"
|
||||
) as dest:
|
||||
nonce, mac = self._key.encrypt_stream(source, dest, mode, nonce)
|
||||
|
||||
metadata = EncryptedMessage(nonce=nonce, mac=mac)
|
||||
|
||||
return metadata.serialize()
|
||||
|
||||
def decrypt_file(
|
||||
self,
|
||||
metadata: str,
|
||||
source_path: GenericPath,
|
||||
dest_path: GenericPath,
|
||||
mode: FileMode,
|
||||
):
|
||||
"""
|
||||
解密响应文件 (由客户端使用).
|
||||
"""
|
||||
message = EncryptedMessage.deserialize(metadata)
|
||||
|
||||
if message.ciphertext is not None or message.key is not None:
|
||||
raise error.ParamError("response", "Message is not server response")
|
||||
if len(message.nonce) != AES_NONCE_LEN or len(message.mac) != AES_MAC_LEN:
|
||||
raise error.DecryptionError("Invalid data length")
|
||||
|
||||
return self.decrypt_file_with_nonce(
|
||||
source_path, dest_path, message.nonce, message.mac, mode
|
||||
)
|
||||
|
||||
def decrypt_file_with_nonce(
|
||||
self,
|
||||
source_path: GenericPath,
|
||||
dest_path: GenericPath,
|
||||
nonce: bytes,
|
||||
mac: Optional[bytes],
|
||||
mode: FileMode,
|
||||
):
|
||||
"""
|
||||
解密响应文件 (由客户端使用).
|
||||
"""
|
||||
with open(source_path, f"r{mode}") as source, open(
|
||||
dest_path, f"w{mode}"
|
||||
) as dest:
|
||||
self._key.decrypt_stream(source, dest, nonce, mac, mode)
|
||||
|
||||
|
||||
@dataclass(eq=False, frozen=True)
|
||||
class ClientSessionKey:
|
||||
"""
|
||||
数据发送方持有的密钥.
|
||||
|
||||
本类不含可变状态, 所有方法是线程安全的.
|
||||
"""
|
||||
|
||||
server_public_key: PublicKey
|
||||
"""数据接收方公钥."""
|
||||
|
||||
@classmethod
|
||||
def load(cls, public_pem: Union[str, bytes]) -> Self:
|
||||
"""
|
||||
通过数据接收方的公钥产生发送方密钥.
|
||||
|
||||
Args:
|
||||
private_pem: PEM 格式的 RSA 公钥.
|
||||
"""
|
||||
public_key = PublicKey.from_public_pem(public_pem)
|
||||
|
||||
return cls(public_key)
|
||||
|
||||
def encrypt(
|
||||
self, plaintext: Union[str, bytes], key: Optional[AesKey] = None
|
||||
) -> EncryptedMessage:
|
||||
"""
|
||||
信封加密.
|
||||
|
||||
Args:
|
||||
key: 指定加密用的对称密钥. 默认为随机生成 (一次一密).
|
||||
Returns:
|
||||
信封加密数据.
|
||||
"""
|
||||
if isinstance(plaintext, str):
|
||||
plaintext = plaintext.encode("utf-8")
|
||||
|
||||
with BytesIO(plaintext) as source, BytesIO() as dest:
|
||||
message = self.encrypt_stream(source, dest, "b", key)
|
||||
message = dataclasses.replace(message, ciphertext=dest.getvalue())
|
||||
|
||||
return message
|
||||
|
||||
def encrypt_with_response(
|
||||
self, plaintext: Union[str, bytes], aes_key: AesKey = None
|
||||
) -> Tuple[EncryptedMessage, ResponseKey]:
|
||||
"""
|
||||
信封加密, 并获取用于解密响应的密钥.
|
||||
"""
|
||||
if aes_key is None:
|
||||
key = AesKey.generate()
|
||||
else:
|
||||
key = aes_key
|
||||
|
||||
return self.encrypt(plaintext, key), ResponseKey(key)
|
||||
|
||||
def encrypt_stream(
|
||||
self, source: IO, dest: IO, mode: FileMode, key: Optional[AesKey] = None
|
||||
) -> EncryptedMessage:
|
||||
"""
|
||||
信封加密数据流.
|
||||
|
||||
Returns:
|
||||
信封加密元数据.
|
||||
"""
|
||||
symmetric_key = key or AesKey.generate()
|
||||
|
||||
nonce, mac = symmetric_key.encrypt_stream(source, dest, mode)
|
||||
|
||||
encrypted_key = self.server_public_key.encrypt(symmetric_key.key)
|
||||
|
||||
return EncryptedMessage(key=encrypted_key, nonce=nonce, mac=mac)
|
||||
|
||||
def encrypt_stream_with_response(
|
||||
self, source: IO, dest: IO, mode: FileMode
|
||||
) -> Tuple[EncryptedMessage, ResponseKey]:
|
||||
"""
|
||||
信封加密数据流, 并获取用于解密响应的密钥.
|
||||
"""
|
||||
key = AesKey.generate()
|
||||
|
||||
# return self.encrypt_stream(source, dest, mode), ResponseKey(key)
|
||||
return self.encrypt_stream(source, dest, mode, key), ResponseKey(key)
|
||||
|
||||
|
||||
@dataclass(eq=False)
|
||||
class ServerSessionKey:
|
||||
"""
|
||||
数据接收方持有的密钥.
|
||||
|
||||
本类的所有方法是线程安全的.
|
||||
"""
|
||||
|
||||
private_key: PrivateKey
|
||||
"""私钥."""
|
||||
|
||||
expire_at: datetime
|
||||
"""【未启用】密钥对在此时间后无效."""
|
||||
|
||||
tks_version: Optional[int]
|
||||
"""【未启用】密钥版本, 用于判断获取的密钥是否变化. """
|
||||
|
||||
pem_md5: Optional[str] = None
|
||||
"""pem的MD5值."""
|
||||
|
||||
used_nonce: Set[bytes] = field(default_factory=set)
|
||||
"""已使用的单次随机数列表."""
|
||||
|
||||
used_nonce_lock: Lock = field(init=False, default_factory=Lock)
|
||||
"""线程安全: 修改 used_nonce 应该加锁."""
|
||||
|
||||
@classmethod
|
||||
def load(
|
||||
cls,
|
||||
private_pem: Union[str, bytes],
|
||||
expire_at: datetime,
|
||||
tks_version: Optional[int],
|
||||
pem_md5: Optional[str],
|
||||
) -> Self:
|
||||
"""
|
||||
通过 PEM 格式的 RSA 私钥产生接收方密钥.
|
||||
|
||||
Args:
|
||||
private_pem: PEM 格式的 RSA 私钥.
|
||||
:param pem_md5:
|
||||
:param private_pem:
|
||||
:param expire_at:
|
||||
:param tks_version:
|
||||
"""
|
||||
private_key = PrivateKey.from_private_pem(private_pem)
|
||||
|
||||
return cls(private_key, expire_at, tks_version, pem_md5)
|
||||
|
||||
@classmethod
|
||||
def generate(cls, expire_at: datetime) -> Self:
|
||||
"""
|
||||
随机生成接收方密钥.
|
||||
"""
|
||||
private_key = PrivateKey.generate()
|
||||
|
||||
return cls(private_key, expire_at, tks_version=None, pem_md5=None)
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
"""
|
||||
是否已超过有效期.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
if now > self.expire_at:
|
||||
logger.warning("session key maybe expired")
|
||||
|
||||
return False # 服务端密钥暂时不考虑有效期
|
||||
|
||||
def get_public_key(self) -> str:
|
||||
"""
|
||||
获取 PEM 格式的公钥.
|
||||
"""
|
||||
return self.private_key.public_pem()
|
||||
|
||||
def decrypt(
|
||||
self, message: Union[str, bytes, EncryptedMessage]
|
||||
) -> Tuple[bytes, ResponseKey]:
|
||||
"""
|
||||
信封解密.
|
||||
|
||||
Returns:
|
||||
明文数据, 及用于加密响应的密钥.
|
||||
|
||||
Raises:
|
||||
DecryptionError: 加密消息不合法, 或者加密使用的密钥不是本密钥.
|
||||
"""
|
||||
if isinstance(message, (str, bytes)):
|
||||
message = EncryptedMessage.deserialize(message)
|
||||
if message.ciphertext is None:
|
||||
raise error.ParamError("message", "Message has no data")
|
||||
|
||||
with BytesIO(message.ciphertext) as source, BytesIO() as dest:
|
||||
response_key = self.decrypt_stream(message, source, dest, "b")
|
||||
plaintext = dest.getvalue()
|
||||
|
||||
return plaintext, response_key
|
||||
|
||||
def decrypt_stream(
|
||||
self,
|
||||
metadata: Union[str, bytes, EncryptedMessage],
|
||||
source: IO,
|
||||
dest: IO,
|
||||
mode: FileMode,
|
||||
) -> ResponseKey:
|
||||
"""
|
||||
信封解密数据流.
|
||||
|
||||
Returns:
|
||||
用于加密响应的密钥.
|
||||
|
||||
Raises:
|
||||
DecryptionError: 加密消息不合法, 或者加密使用的密钥不是本密钥.
|
||||
"""
|
||||
if isinstance(metadata, (str, bytes)):
|
||||
metadata = EncryptedMessage.deserialize(metadata)
|
||||
if metadata.key is None:
|
||||
raise error.ParamError("metadata", "Message has no key")
|
||||
|
||||
# with self.used_nonce_lock:
|
||||
# if metadata.nonce in self.used_nonce:
|
||||
# raise error.NonceReusedError()
|
||||
# self.used_nonce.add(metadata.nonce)
|
||||
|
||||
symmetric_key = self.private_key.decrypt(metadata.key)
|
||||
if (
|
||||
len(symmetric_key) != AES_KEY_LEN
|
||||
or len(metadata.nonce) != AES_NONCE_LEN
|
||||
or len(metadata.mac) != AES_MAC_LEN
|
||||
):
|
||||
raise error.DecryptionError("Invalid data length")
|
||||
|
||||
symmetric_key = AesKey(symmetric_key)
|
||||
symmetric_key.decrypt_stream(source, dest, metadata.nonce, metadata.mac, mode)
|
||||
|
||||
return ResponseKey(symmetric_key)
|
||||
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
RSA 加密和签名算法.
|
||||
"""
|
||||
|
||||
__all__ = ["PublicKey", "PrivateKey"]
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
|
||||
from typing_extensions import Self, Union
|
||||
|
||||
from .. import error
|
||||
|
||||
ENCRYPT_PADDING = padding.OAEP(
|
||||
mgf=padding.MGF1(hashes.SHA256()), algorithm=hashes.SHA256(), label=None
|
||||
)
|
||||
SIGN_PADDING = padding.PSS(
|
||||
mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH
|
||||
)
|
||||
SIGN_HASH = hashes.SHA256()
|
||||
|
||||
|
||||
@dataclass(eq=False, frozen=True)
|
||||
class PublicKey:
|
||||
"""
|
||||
RSA 公钥.
|
||||
|
||||
本类不含可变状态, 所有方法是线程安全的.
|
||||
"""
|
||||
|
||||
public_key: RSAPublicKey
|
||||
|
||||
@classmethod
|
||||
def from_public_pem(cls, public_pem: Union[str, bytes]) -> Self:
|
||||
"""
|
||||
读取 PEM 格式的 RSA 公钥.
|
||||
|
||||
Args:
|
||||
private_pem: PEM 格式的 RSA 公钥.
|
||||
"""
|
||||
if isinstance(public_pem, str):
|
||||
public_pem = public_pem.encode("utf-8")
|
||||
try:
|
||||
public_key = serialization.load_pem_public_key(public_pem)
|
||||
except Exception as e:
|
||||
raise error.ParamError("public_pem", "Invalid RSA public key") from e
|
||||
if not isinstance(public_key, RSAPublicKey):
|
||||
raise error.ParamError("public_pem", "Key is not RSA")
|
||||
|
||||
return cls(public_key)
|
||||
|
||||
def encrypt(self, plaintext: bytes) -> bytes:
|
||||
"""
|
||||
RSA 加密.
|
||||
|
||||
Raises:
|
||||
EncryptionError:
|
||||
"""
|
||||
try:
|
||||
return self.public_key.encrypt(plaintext, ENCRYPT_PADDING)
|
||||
except Exception as e:
|
||||
raise error.EncryptionError("RSA encryption failed") from e
|
||||
|
||||
def public_pem(self) -> str:
|
||||
"""
|
||||
获取 PEM 格式的公钥.
|
||||
"""
|
||||
return self.public_key.public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
).decode("utf-8")
|
||||
|
||||
def verify(self, signature: bytes, message: Union[str, bytes]) -> bool:
|
||||
"""
|
||||
公钥验签.
|
||||
|
||||
Raises:
|
||||
SignatureError:
|
||||
"""
|
||||
if isinstance(message, str):
|
||||
message = message.encode("utf-8")
|
||||
try:
|
||||
self.public_key.verify(signature, message, SIGN_PADDING, SIGN_HASH)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return False
|
||||
|
||||
|
||||
@dataclass(init=False, eq=False, frozen=True)
|
||||
class PrivateKey(PublicKey):
|
||||
"""
|
||||
RSA 密钥对.
|
||||
"""
|
||||
|
||||
private_key: RSAPrivateKey
|
||||
|
||||
def __init__(self, private_key: RSAPrivateKey):
|
||||
# Workaround frozen class
|
||||
object.__setattr__(self, "private_key", private_key)
|
||||
object.__setattr__(self, "public_key", private_key.public_key())
|
||||
|
||||
@classmethod
|
||||
def from_private_pem(cls, private_pem: Union[str, bytes]) -> Self:
|
||||
"""
|
||||
读取 PEM 格式的 RSA 密钥对.
|
||||
|
||||
Args:
|
||||
private_pem: PEM 格式的 RSA 私钥.
|
||||
"""
|
||||
if isinstance(private_pem, str):
|
||||
private_pem = private_pem.encode("utf-8")
|
||||
private_key = serialization.load_pem_private_key(private_pem, password=None)
|
||||
if not isinstance(private_key, RSAPrivateKey):
|
||||
raise error.ParamError("private_pem", "Key is not RSA")
|
||||
|
||||
return cls(private_key)
|
||||
|
||||
@classmethod
|
||||
def generate(cls) -> Self:
|
||||
"""
|
||||
随机生成密钥对.
|
||||
"""
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=4096)
|
||||
return cls(private_key)
|
||||
|
||||
def private_pem(self) -> str:
|
||||
"""
|
||||
获取 PEM 格式的私钥.
|
||||
"""
|
||||
return self.private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
).decode("utf-8")
|
||||
|
||||
def decrypt(self, plaintext: bytes) -> bytes:
|
||||
"""
|
||||
RSA 解密.
|
||||
|
||||
Raises:
|
||||
DecryptionError:
|
||||
"""
|
||||
try:
|
||||
return self.private_key.decrypt(plaintext, ENCRYPT_PADDING)
|
||||
except Exception as e:
|
||||
raise error.DecryptionError("RSA decryption failed") from e
|
||||
|
||||
def sign(self, message: Union[str, bytes]) -> bytes:
|
||||
"""
|
||||
私钥签名.
|
||||
|
||||
Raises:
|
||||
SignatureError:
|
||||
"""
|
||||
if isinstance(message, str):
|
||||
message = message.encode("utf-8")
|
||||
try:
|
||||
signature = self.private_key.sign(message, SIGN_PADDING, SIGN_HASH)
|
||||
return signature
|
||||
except Exception as e:
|
||||
raise error.SignatureError("RSA signing failed") from e
|
||||
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
安全通信错误类型.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
from typing_extensions import Literal, Optional
|
||||
|
||||
|
||||
class ErrorKind(Enum):
|
||||
CONFIG = "config"
|
||||
PARAM = "param"
|
||||
|
||||
NETWORK = "network"
|
||||
SERVICE = "service"
|
||||
|
||||
ENCRYPTION = "encryption"
|
||||
DECRYPTION = "decryption"
|
||||
SIGNATURE = "signature"
|
||||
KEY_MISSING = "key_missing"
|
||||
KEY_EXPIRED = "key_expired"
|
||||
NONCE_REUSED = "nonce_reused"
|
||||
|
||||
|
||||
class SecureChannelError(Exception):
|
||||
kind: ErrorKind
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConfigError(SecureChannelError):
|
||||
kind: ErrorKind = field(init=False, repr=False, default=ErrorKind.CONFIG)
|
||||
|
||||
config_object: object
|
||||
message: str
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.config_object}: {self.message}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParamError(SecureChannelError):
|
||||
kind: ErrorKind = field(init=False, repr=False, default=ErrorKind.PARAM)
|
||||
|
||||
param: str
|
||||
message: str
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.param}: {self.message}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class NetworkError(SecureChannelError):
|
||||
kind: ErrorKind = field(init=False, repr=False, default=ErrorKind.NETWORK)
|
||||
|
||||
service_name: Literal["EPS", "RA", "RAS", "TKS"]
|
||||
service: str
|
||||
endpoint: Optional[str]
|
||||
|
||||
def _service(self) -> str:
|
||||
if self.endpoint is None:
|
||||
return f"{self.service_name} service {self.service}"
|
||||
else:
|
||||
return f"endpoint {self.endpoint} of {self.service_name} service {self.service}"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Cannot access {self._service()}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServiceError(NetworkError):
|
||||
kind: ErrorKind = field(init=False, repr=False, default=ErrorKind.SERVICE)
|
||||
|
||||
message: str
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self._service().capitalize()} returned error: {self.message}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class EncryptionError(SecureChannelError):
|
||||
kind: ErrorKind = field(init=False, repr=False, default=ErrorKind.ENCRYPTION)
|
||||
|
||||
message: str
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.message
|
||||
|
||||
|
||||
@dataclass
|
||||
class DecryptionError(SecureChannelError):
|
||||
kind: ErrorKind = field(init=False, repr=False, default=ErrorKind.DECRYPTION)
|
||||
|
||||
message: str
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.message
|
||||
|
||||
|
||||
@dataclass
|
||||
class SignatureError(SecureChannelError):
|
||||
kind: ErrorKind = field(init=False, repr=False, default=ErrorKind.SIGNATURE)
|
||||
|
||||
message: str
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.message
|
||||
|
||||
|
||||
@dataclass
|
||||
class KeyMissingError(SecureChannelError):
|
||||
kind: ErrorKind = field(init=False, repr=False, default=ErrorKind.KEY_MISSING)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "Can not get key, Please check your config !"
|
||||
|
||||
|
||||
@dataclass
|
||||
class KeyExpiredError(SecureChannelError):
|
||||
kind: ErrorKind = field(init=False, repr=False, default=ErrorKind.KEY_EXPIRED)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "Key has expired"
|
||||
|
||||
|
||||
@dataclass
|
||||
class NonceReusedError(SecureChannelError):
|
||||
kind: ErrorKind = field(init=False, repr=False, default=ErrorKind.NONCE_REUSED)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "Nonce is reused"
|
||||
@@ -0,0 +1,24 @@
|
||||
__all__ = [
|
||||
"DEBUG",
|
||||
"INFO",
|
||||
"WARNING",
|
||||
"ERROR",
|
||||
"CRITICAL",
|
||||
"FORMAT",
|
||||
"LogConfig",
|
||||
"debug",
|
||||
"info",
|
||||
"warning",
|
||||
"error",
|
||||
"critical",
|
||||
"init_log_config"
|
||||
]
|
||||
|
||||
from .config import DEBUG, INFO, WARNING, ERROR, CRITICAL, FORMAT, LogConfig
|
||||
from .logger import init_log_config, info, debug, warning, error, critical
|
||||
|
||||
_initialized = False
|
||||
if not _initialized:
|
||||
config = LogConfig()
|
||||
init_log_config(config)
|
||||
_initialized = True
|
||||
@@ -0,0 +1,31 @@
|
||||
__all__ = [
|
||||
"DEBUG",
|
||||
"INFO",
|
||||
"WARNING",
|
||||
"ERROR",
|
||||
"CRITICAL",
|
||||
"FORMAT",
|
||||
"LogConfig"
|
||||
]
|
||||
|
||||
# Log level constants
|
||||
DEBUG = "DEBUG"
|
||||
INFO = "INFO"
|
||||
WARNING = "WARNING"
|
||||
ERROR = "ERROR"
|
||||
CRITICAL = "CRITICAL"
|
||||
# Log output format
|
||||
FORMAT = "{time:YYYY-MM-DD HH:mm:ss} | {level} | {file.path}:{line} | {message}"
|
||||
|
||||
|
||||
class LogConfig:
|
||||
"""Log configuration class."""
|
||||
|
||||
def __init__(self):
|
||||
self.rotation = 100 # Size limit for log files in MB
|
||||
self.retention = 7 # Days to retain log files
|
||||
self.compression = "zip"
|
||||
self.dir = "jsc_log"
|
||||
self.filename = "jsc.log"
|
||||
self.format = FORMAT
|
||||
self.level = INFO
|
||||
@@ -0,0 +1,88 @@
|
||||
__all__ = [
|
||||
"debug",
|
||||
"info",
|
||||
"warning",
|
||||
"error",
|
||||
"critical",
|
||||
"init_log_config"
|
||||
]
|
||||
|
||||
import os.path
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from . import LogConfig, FORMAT
|
||||
|
||||
|
||||
def init_log_config(*configs: LogConfig):
|
||||
"""Initialize logging configuration with given configs."""
|
||||
logger.remove()
|
||||
|
||||
log_level = "INFO"
|
||||
if os.environ.get("log_level"):
|
||||
log_level = os.environ.get("log_level")
|
||||
if os.environ.get("LOG_LEVEL"):
|
||||
log_level = os.environ.get("LOG_LEVEL")
|
||||
|
||||
logger.add(sink=sys.stdout, format=FORMAT, level=log_level)
|
||||
|
||||
for config in configs:
|
||||
if not config.dir:
|
||||
config.dir = os.environ.get("LOG_DIR", "aicc_log")
|
||||
|
||||
if not config.filename:
|
||||
config.filename = "jsc.log"
|
||||
|
||||
os.makedirs(config.dir, exist_ok=True)
|
||||
fullpath = os.path.join(config.dir, config.filename)
|
||||
|
||||
if config.rotation <= 0:
|
||||
config.rotation = 100
|
||||
rotation = f"{config.rotation} MB"
|
||||
|
||||
if config.retention <= 0:
|
||||
config.retention = 7
|
||||
retention = f"{config.retention} days"
|
||||
|
||||
logger.add(
|
||||
sink=fullpath,
|
||||
rotation=rotation,
|
||||
retention=retention,
|
||||
compression=config.compression,
|
||||
format=config.format,
|
||||
level=config.level
|
||||
)
|
||||
|
||||
|
||||
def msg_proc(message: str) -> str:
|
||||
if message:
|
||||
return f"[jsc]:{message}"
|
||||
else:
|
||||
return message
|
||||
|
||||
|
||||
def debug(message: str, *args: Any, **kwargs: Any) -> None:
|
||||
"""Log debug message."""
|
||||
logger.opt(depth=1).debug(msg_proc(message), *args, **kwargs)
|
||||
|
||||
|
||||
def info(message: str, *args: Any, **kwargs: Any) -> None:
|
||||
"""Log info message."""
|
||||
logger.opt(depth=1).info(msg_proc(message), *args, **kwargs)
|
||||
|
||||
|
||||
def warning(message: str, *args: Any, **kwargs: Any) -> None:
|
||||
"""Log warning message."""
|
||||
logger.opt(depth=1).warning(msg_proc(message), *args, **kwargs)
|
||||
|
||||
|
||||
def error(message: str, *args: Any, **kwargs: Any) -> None:
|
||||
"""Log error message."""
|
||||
logger.opt(depth=1).error(msg_proc(message), *args, **kwargs)
|
||||
|
||||
|
||||
def critical(message: str, *args: Any, **kwargs: Any) -> None:
|
||||
"""Log critical message."""
|
||||
logger.opt(depth=1).critical(msg_proc(message), *args, **kwargs)
|
||||
@@ -0,0 +1,134 @@
|
||||
import logging
|
||||
import multiprocessing
|
||||
import os
|
||||
import sys
|
||||
|
||||
mutex = multiprocessing.Lock()
|
||||
|
||||
|
||||
class Factory(object):
|
||||
@staticmethod
|
||||
def init(*args):
|
||||
"""
|
||||
Initialize a global instance
|
||||
:param args:
|
||||
:return:
|
||||
"""
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def get_global_instance():
|
||||
"""
|
||||
Get the global instance
|
||||
:return:
|
||||
"""
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def get_instance(*args):
|
||||
"""
|
||||
Get an instance
|
||||
:param args:
|
||||
:return:
|
||||
"""
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _raise_value_error(invalid_type, invalid_value):
|
||||
"""
|
||||
|
||||
:param invalid_type: str
|
||||
:param invalid_value: str
|
||||
:return:
|
||||
"""
|
||||
raise ValueError("invalid {}: {}".format(invalid_type, invalid_value))
|
||||
|
||||
|
||||
class LoggerFactory(Factory):
|
||||
logger = None
|
||||
root_dir = ""
|
||||
task_logger_level = ""
|
||||
local_logger = {}
|
||||
|
||||
@staticmethod
|
||||
def init():
|
||||
top_logger_level = os.getenv("TOP_LOGGER_LEVEL", "DEBUG").strip('"')
|
||||
log_path = os.getenv("LOGDIR", f"./jsc_log").strip('"')
|
||||
task_logger_level = os.getenv("TASK_LOGGER_LEVEL", "DEBUG").strip('"')
|
||||
|
||||
LoggerFactory.task_logger_level = task_logger_level
|
||||
|
||||
try:
|
||||
import bytedlogger
|
||||
|
||||
bytedlogger.config_default()
|
||||
LoggerFactory.logger = logging.getLogger("top")
|
||||
except ImportError:
|
||||
LoggerFactory.logger = logging.getLogger("top")
|
||||
LoggerFactory.logger.setLevel(top_logger_level)
|
||||
|
||||
if log_path is None or log_path == "":
|
||||
log_path = "/home/jeddak/log"
|
||||
print(f"Root logger path is not set, use log_path {log_path}")
|
||||
|
||||
print(f"log_path: {log_path}")
|
||||
LoggerFactory.root_dir = log_path
|
||||
|
||||
if not os.path.exists(log_path):
|
||||
os.makedirs(log_path)
|
||||
|
||||
top_logger_path = os.path.join(log_path, "top.log")
|
||||
|
||||
fh = logging.FileHandler(top_logger_path, mode="a")
|
||||
fh.setLevel(top_logger_level)
|
||||
formatter = logging.Formatter(
|
||||
"%(asctime)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s"
|
||||
)
|
||||
fh.setFormatter(formatter)
|
||||
LoggerFactory.logger.addHandler(fh)
|
||||
# LoggerFactory.logger.addHandler(logging.StreamHandler(sys.stdout))
|
||||
|
||||
@staticmethod
|
||||
def get_global_instance():
|
||||
if LoggerFactory.logger is None:
|
||||
LoggerFactory.init()
|
||||
return LoggerFactory.logger
|
||||
|
||||
@staticmethod
|
||||
def attach_logger(logger, log_path, log_file_name, logger_level, _formatter):
|
||||
with mutex:
|
||||
if not os.path.exists(log_path):
|
||||
os.makedirs(log_path)
|
||||
logger.setLevel(logger_level)
|
||||
_fh = logging.FileHandler(log_file_name, mode="a")
|
||||
_fh.setLevel(logger_level)
|
||||
# _formatter = logging.Formatter(
|
||||
# f'%(asctime)s - %(levelname)s - %(filename)s:%(lineno)d - {logger_str} - %(message)s')
|
||||
_fh.setFormatter(_formatter)
|
||||
|
||||
logger.addHandler(_fh)
|
||||
logger.addHandler(logging.StreamHandler(sys.stdout))
|
||||
return logger
|
||||
|
||||
@staticmethod
|
||||
def get_local_instance(log_id="jsc_log"):
|
||||
if LoggerFactory.logger is None:
|
||||
LoggerFactory.init()
|
||||
|
||||
logger_name = log_id
|
||||
if logger_name in LoggerFactory.local_logger:
|
||||
return LoggerFactory.local_logger[logger_name]
|
||||
|
||||
log_path = os.path.join(LoggerFactory.root_dir, log_id)
|
||||
log_file_name = os.path.join(log_path, logger_name + ".log")
|
||||
|
||||
_logger = logging.getLogger(log_id)
|
||||
|
||||
_formatter = logging.Formatter(
|
||||
f"%(asctime)s - %(levelname)s - %(filename)s:%(lineno)d - {logger_name} - %(message)s"
|
||||
)
|
||||
_logger = LoggerFactory.attach_logger(
|
||||
_logger, log_path, log_file_name, LoggerFactory.task_logger_level, _formatter
|
||||
)
|
||||
LoggerFactory.local_logger[logger_name] = _logger
|
||||
return _logger
|
||||
@@ -0,0 +1,768 @@
|
||||
"""
|
||||
安全通信远程证明功能.
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
"RaConfig",
|
||||
"RaPod",
|
||||
"RaRequest",
|
||||
"RaResponse",
|
||||
"RaResponseMetadata",
|
||||
"RaResponsePod",
|
||||
"RaResponsePods",
|
||||
"attest_server",
|
||||
"prepare_ra_request",
|
||||
"validata_ra_request",
|
||||
"attest_client",
|
||||
"generate_nonce",
|
||||
]
|
||||
|
||||
import time
|
||||
import uuid
|
||||
import base64
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Tuple
|
||||
import jwt
|
||||
import hashlib
|
||||
from pymerkle import verify_inclusion, MerkleProof
|
||||
from jwcrypto import jwk
|
||||
from jwt.exceptions import ExpiredSignatureError, InvalidIssuerError, InvalidSignatureError
|
||||
from volcenginesdkcore.rest import ApiException
|
||||
import requests
|
||||
from typing_extensions import (
|
||||
Dict,
|
||||
List,
|
||||
Literal,
|
||||
NotRequired,
|
||||
Optional,
|
||||
TypedDict,
|
||||
Union,
|
||||
)
|
||||
|
||||
from . import error
|
||||
from .crypto import PublicKey
|
||||
from .log import logger
|
||||
from .utils import request_bytedance_gateway
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa, padding
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
|
||||
|
||||
RA_NONCE_LEN = 12
|
||||
RA_TYPE_LOCAL = "local"
|
||||
RA_TYPE_TCA = "tca"
|
||||
|
||||
|
||||
class RaPod(TypedDict):
|
||||
cluster_id: str
|
||||
|
||||
namespace: str
|
||||
|
||||
deployment: str
|
||||
|
||||
pod: NotRequired[Literal["random", "all"]]
|
||||
"""默认为 random, 如果想要全部则为 all."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class RaConfig:
|
||||
"""
|
||||
访问远程证明功能的配置.
|
||||
"""
|
||||
|
||||
ra_url: str = ""
|
||||
"""远程证明端点的 HTTP 地址."""
|
||||
|
||||
ra_type: str = RA_TYPE_TCA
|
||||
"""ra的类型,local:本地RA, tca:TCA的RAS_adapter接口(默认)."""
|
||||
|
||||
ra_service_name: str = ""
|
||||
"""数据接收端(服务端)的服务名. ,用于验证通过机密容器部署的服务"""
|
||||
|
||||
ra_pods_info: str = ""
|
||||
"""
|
||||
待验证的节点的pod信息,用于验证非通过机密容器部署的服务,
|
||||
格式: {cluster_id:***, namespace:***, deployment:***}
|
||||
ra_service_name 和 ra_pods_info 二选一
|
||||
"""
|
||||
|
||||
ra_uid: str = ""
|
||||
"""用户的UID"""
|
||||
|
||||
ra_key_negotiation: bool = True
|
||||
"""是否在验证的时候需要同步进行协商密钥."""
|
||||
|
||||
ra_need_token: bool = True # RA 功能稳定后要改成 False
|
||||
"""是否要返回验证 token. 默认为是."""
|
||||
|
||||
attest_must: bool = False
|
||||
"""是否一定要返回验证报告才能采用公钥."""
|
||||
|
||||
ra_policy_id: str = ""
|
||||
"""进行验证所使用的策略, 如果 ra_need_token 为 True, 则必填."""
|
||||
|
||||
bytedance_top_info: str = ""
|
||||
"""访问bytedance TOP网关的信息."""
|
||||
|
||||
ra_attested_pods: List[RaPod] = field(default_factory=list)
|
||||
"""old 需要验证的节点信息."""
|
||||
|
||||
ra_policy_ids: List[str] = field(default_factory=list)
|
||||
"""old 进行验证所使用的策略, 如果 ra_need_token 为 True, 则必填."""
|
||||
|
||||
auth_token: str = ""
|
||||
"""用来进行鉴权的凭证"""
|
||||
|
||||
|
||||
class RaRequest(TypedDict):
|
||||
"""数据发送方发出的远程证明请求."""
|
||||
|
||||
key_negotiation: bool
|
||||
"""是否在验证的时候需要同步进行协商密钥."""
|
||||
|
||||
token: bool
|
||||
"""是否要返回验证 token."""
|
||||
|
||||
policy_ids: List[str]
|
||||
"""进行验证所使用的策略."""
|
||||
|
||||
attested_pods: List[RaPod]
|
||||
"""需要验证的节点信息."""
|
||||
|
||||
|
||||
class RaResponseMetadata(TypedDict):
|
||||
Code: int
|
||||
Message: str
|
||||
|
||||
|
||||
class RaResponsePod(TypedDict):
|
||||
evidence: str
|
||||
token: str
|
||||
key_info: dict
|
||||
|
||||
|
||||
RaResponsePods = Dict[str, RaResponsePod]
|
||||
|
||||
|
||||
class RaResponse(TypedDict):
|
||||
"""数据接收方发出的远程证明响应."""
|
||||
|
||||
ResponseMetadata: RaResponseMetadata
|
||||
|
||||
ResponseResult: RaResponsePods
|
||||
|
||||
|
||||
def prepare_ra_request(config: RaConfig) -> RaRequest:
|
||||
"""
|
||||
准备远程证明请求.
|
||||
|
||||
Returns:
|
||||
远程证明请求. 可作为 JSON 发送给数据接收方远程证明端点.
|
||||
"""
|
||||
return RaRequest(
|
||||
key_negotiation=config.ra_key_negotiation,
|
||||
token=config.ra_need_token,
|
||||
policy_ids=config.ra_policy_ids,
|
||||
attested_pods=config.ra_attested_pods,
|
||||
)
|
||||
|
||||
|
||||
def validata_ra_request(request: RaRequest) -> None:
|
||||
"""
|
||||
验证远程证明请求有效性.
|
||||
|
||||
Raises:
|
||||
ParamError: 请求的 nonce 或 timestamp 字段无效.
|
||||
"""
|
||||
nonce = request.get("hex_runtime_data")
|
||||
if nonce:
|
||||
nonce = base64.b64decode(nonce)
|
||||
if len(nonce) != RA_NONCE_LEN:
|
||||
raise error.ParamError("nonce", "Invalid RA request")
|
||||
|
||||
# time = datetime.fromtimestamp(request["timestamp"], timezone.utc)
|
||||
# now = datetime.now(timezone.utc)
|
||||
# if time > now + timedelta(minutes=1) or time < now - timedelta(minutes=2):
|
||||
# raise error.ParamError("timestamp", "Invalid RA request")
|
||||
|
||||
|
||||
def generate_nonce():
|
||||
return ''.join(str(uuid.uuid4()).split('-'))[-16:]
|
||||
|
||||
|
||||
def request_tca(config: RaConfig, nonce: str = None):
|
||||
try:
|
||||
ra_url = config.ra_url
|
||||
ra_service_name = config.ra_service_name
|
||||
ra_pods_info = config.ra_pods_info
|
||||
ra_key_negotiation = config.ra_key_negotiation
|
||||
ra_policy_id = config.ra_policy_id
|
||||
ra_uid = config.ra_uid
|
||||
|
||||
top_info = config.bytedance_top_info
|
||||
if top_info and isinstance(top_info, str):
|
||||
top_info = json.loads(top_info)
|
||||
# assert isinstance(top_info, dict)
|
||||
|
||||
headers = {"UID": ra_uid}
|
||||
|
||||
if not nonce:
|
||||
nonce = generate_nonce()
|
||||
|
||||
if ra_service_name:
|
||||
body = {
|
||||
"Nonce": nonce,
|
||||
"PolicyID": ra_policy_id,
|
||||
"ServiceName": ra_service_name,
|
||||
"KeyNegotiation": ra_key_negotiation,
|
||||
"Token": True,
|
||||
"Version": 2
|
||||
}
|
||||
elif ra_pods_info:
|
||||
if isinstance(ra_pods_info, str):
|
||||
ra_pods_info = json.loads(ra_pods_info)
|
||||
body = {
|
||||
"Nonce": nonce,
|
||||
"PolicyID": ra_policy_id,
|
||||
"AttestedPods": [ra_pods_info],
|
||||
"KeyNegotiation": ra_key_negotiation,
|
||||
"Token": True,
|
||||
"Version": 2
|
||||
}
|
||||
else:
|
||||
raise error.ServiceError("RA", config.ra_url, None, "call TCA error")
|
||||
|
||||
if top_info.get("target_uid") and not top_info.get("aicc_saas_trn"):
|
||||
from bytedance.jeddak_secure_channel.utils import top_request
|
||||
return top_request(ra_url, body, headers, top_info)
|
||||
else:
|
||||
return request_bytedance_gateway(ra_url, body, headers, top_info)
|
||||
except Exception as e:
|
||||
logger.critical(f"Response is not JSON: service={config.ra_url}")
|
||||
raise error.ServiceError("RA", config.ra_url, None, "call TCA error") from e
|
||||
|
||||
|
||||
def attest_server_local(token: Optional[str], config: RaConfig, nonce=None):
|
||||
"""
|
||||
对数据接收方进行远程证明(非TCA方式).
|
||||
"""
|
||||
timestamp = str(int(datetime.now(timezone.utc).timestamp()))
|
||||
auth_token = token
|
||||
if not auth_token:
|
||||
auth_token = config.auth_token
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"timestamp": f"{timestamp}",
|
||||
"Authorization": f"Bearer {auth_token}"
|
||||
}
|
||||
|
||||
if not nonce:
|
||||
nonce = generate_nonce()
|
||||
|
||||
request = {"Nonce": nonce}
|
||||
try:
|
||||
logger.info(
|
||||
f"request ra, url={config.ra_url}, header={headers}, params={request}"
|
||||
)
|
||||
response = requests.post(config.ra_url, headers=headers, json=request)
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.critical(f"RA Network error: url={config.ra_url}")
|
||||
raise error.NetworkError("RA", config.ra_url, endpoint=None) from e
|
||||
|
||||
# if response.status_code != 200:
|
||||
# logger.error(
|
||||
# f"Service error: service={config.ra_url} code={response.status_code}"
|
||||
# )
|
||||
# raise error.ServiceError(
|
||||
# "RA", config.ra_url, None, f"code={response.status_code}"
|
||||
# )
|
||||
|
||||
# try:
|
||||
# response_json: RaResponse = response.json()
|
||||
# logger.info(f"ra response={response_json}")
|
||||
#
|
||||
# res_meta_data = response_json.get("ResponseMetadata")
|
||||
# res_result = response_json.get("ResponseResult")
|
||||
#
|
||||
# if not res_meta_data or not res_result:
|
||||
# raise error.ServiceError("RA", config.ra_url, None, "wrong format")
|
||||
#
|
||||
# if res_meta_data.get("Code") != 0:
|
||||
# raise error.ServiceError(
|
||||
# "RA", config.ra_url, None, res_meta_data.get("Message", "")
|
||||
# )
|
||||
#
|
||||
# for pod_name, pod_ra_info in res_result.items():
|
||||
# # evidence = pod_ra_info.get("evidence")
|
||||
# # token = pod_ra_info.get("token")
|
||||
# key_info = pod_ra_info.get("key_info")
|
||||
# if not key_info:
|
||||
# raise error.ServiceError("RA", config.ra_url, "RSA key", "null")
|
||||
#
|
||||
# return True, key_info["pub_key_info"]
|
||||
# except Exception as e:
|
||||
# logger.critical(f"Response is not JSON: service={config.ra_url}")
|
||||
# raise error.ServiceError("RA", config.ra_url, None, "not JSON") from e
|
||||
#
|
||||
# return False, ""
|
||||
|
||||
|
||||
def verify_jwt_token(token):
|
||||
try:
|
||||
tmp_payload = token.split(".")[1]
|
||||
jwk_data = json.loads(base64.urlsafe_b64decode(tmp_payload + "=" * (4 - len(tmp_payload) % 4))).get('jwk')
|
||||
|
||||
public_key = jwk.JWK(**jwk_data)
|
||||
|
||||
# 转换为 PEM 格式,以便 PyJWT 使用
|
||||
pem_public_key = public_key.export_to_pem()
|
||||
|
||||
header = jwt.get_unverified_header(token)
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
# 验证 Token
|
||||
payload = jwt.decode(token, pem_public_key, algorithms=[header.get('alg')],
|
||||
issuer="Bytedance-Remote-Attestation-Service",
|
||||
options={"verify_signature": True})
|
||||
|
||||
logger.debug(f"Token is valid. Payload:{payload}")
|
||||
return True
|
||||
except ExpiredSignatureError:
|
||||
logger.warning("verify_ra_token: Token has expired.")
|
||||
return False
|
||||
except InvalidIssuerError:
|
||||
logger.warning("verify_ra_token: Invalid issuer.")
|
||||
return False
|
||||
except InvalidSignatureError:
|
||||
logger.warning("verify_ra_token: Invalid signature.")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning(f"verify_ra_token: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def base64url_decode(encoded_str):
|
||||
padded_str = encoded_str + '=' * ((4 - len(encoded_str) % 4) % 4) # 补齐padding
|
||||
return base64.urlsafe_b64decode(padded_str)
|
||||
|
||||
|
||||
def jwk_to_rsa_public_key(jwk_info: dict):
|
||||
"""将JWK格式的公钥转换为RSA公钥对象"""
|
||||
# 从JWK中提取必要的参数
|
||||
n = int.from_bytes(base64url_decode(jwk_info['n']), byteorder='big')
|
||||
e = int.from_bytes(base64url_decode(jwk_info['e']), byteorder='big')
|
||||
public_key = rsa.RSAPublicNumbers(e, n).public_key(default_backend())
|
||||
return public_key
|
||||
|
||||
|
||||
def verify_jwt_token_new(token):
|
||||
"""验证JWT token,不依赖JWT库"""
|
||||
try:
|
||||
# 分割JWT:header.payload.signature
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
logger.error("verify_ra_token: Invalid token format")
|
||||
return False
|
||||
|
||||
header_encoded, payload_encoded, signature_encoded = parts
|
||||
|
||||
header = json.loads(base64url_decode(header_encoded).decode('utf-8'))
|
||||
payload = json.loads(base64url_decode(payload_encoded).decode('utf-8'))
|
||||
|
||||
# 验证token是否过期
|
||||
if 'exp' in payload:
|
||||
if payload['exp'] < int(time.time()):
|
||||
logger.warning("verify_ra_token: Token has expired.")
|
||||
|
||||
# 验证issuer
|
||||
if payload.get('iss') != "Bytedance-Remote-Attestation-Service":
|
||||
logger.error("verify_ra_token: Invalid issuer.")
|
||||
return False
|
||||
|
||||
# 签名验证
|
||||
if 'jwk' in header:
|
||||
public_key = jwk_to_rsa_public_key(header['jwk'])
|
||||
alg = header.get("alg")
|
||||
elif 'jwk' in payload:
|
||||
public_key = jwk_to_rsa_public_key(payload['jwk'])
|
||||
alg = payload.get('jwk').get("alg")
|
||||
else:
|
||||
public_key = None
|
||||
alg = None
|
||||
logger.warning("can not find pub_key from JWT token")
|
||||
|
||||
if public_key:
|
||||
message = f"{header_encoded}.{payload_encoded}".encode('utf-8')
|
||||
signature = base64url_decode(signature_encoded)
|
||||
try:
|
||||
if alg == "RS384":
|
||||
public_key.verify(signature, message, padding.PKCS1v15(), hashes.SHA384())
|
||||
else:
|
||||
logger.warning(f"no support JWT sign algorithms: [{alg}]")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"JWT Token Signature verification failed: {e}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"verify_jwt_token Exception={e}")
|
||||
return False
|
||||
|
||||
|
||||
def gen_report_data(nonce_up, nonce_down, dh_param, expiration_time):
|
||||
try:
|
||||
nonce_up_raw = bytes.fromhex(nonce_up)
|
||||
nonce_down_raw = base64.b16decode(nonce_down.upper())
|
||||
dh_param_bytes = base64.b64decode(dh_param)
|
||||
expiration_time_bytes = str(expiration_time).encode()
|
||||
report_data_input = nonce_up_raw + nonce_down_raw + dh_param_bytes + expiration_time_bytes
|
||||
h = hashlib.sha256()
|
||||
h.update(report_data_input)
|
||||
report_data = h.hexdigest()
|
||||
|
||||
h = hashlib.sha512()
|
||||
h.update(report_data.encode())
|
||||
report_data_bytes = h.digest()
|
||||
|
||||
return report_data_bytes
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"gen report data,msg={str(e)}")
|
||||
raise Exception(f"gen report data,msg={str(e)}")
|
||||
|
||||
|
||||
def verify_nonce(proof, report_data, nonce, pub_key_info):
|
||||
try:
|
||||
# 前64位是nonce的sha256哈希,后64位是公钥的sha256哈希
|
||||
nonce_hash = hashlib.sha256(nonce.encode()).hexdigest()
|
||||
|
||||
# 计算pub_key_info的SHA256哈希
|
||||
if pub_key_info:
|
||||
pub_key_hash = hashlib.sha256(pub_key_info.encode()).hexdigest()
|
||||
else:
|
||||
# 如果公钥为空,用64个0替换
|
||||
pub_key_hash = "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
|
||||
# 拼接两个哈希值
|
||||
base = nonce_hash + pub_key_hash
|
||||
logger.debug(f'base={base}')
|
||||
|
||||
if not proof:
|
||||
logger.debug(f'report_data={report_data}')
|
||||
return report_data == base
|
||||
|
||||
h = hashlib.sha512()
|
||||
h.update(b'\x00' + base.encode())
|
||||
base = h.hexdigest()
|
||||
root = report_data
|
||||
|
||||
logger.debug(f"new base={base}")
|
||||
|
||||
proof = MerkleProof.deserialize(proof)
|
||||
verify_inclusion(bytes.fromhex(base), bytes.fromhex(root), proof)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"verify nonce failed!!!, msg={str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def check_pubkey_valid(pk_s: str, pk_r: str, timestamp: str, version: str, service_name: str, sign: str) -> bool:
|
||||
"""
|
||||
对pk_s的正确性进行检验
|
||||
:param pk_s:服务公钥
|
||||
:param pk_r:rar公钥
|
||||
:param timestamp:时间戳
|
||||
:param version:version
|
||||
:param service_name:version
|
||||
:param sign: sign(pk_s+timestamp, pr_r)
|
||||
:return: True/False
|
||||
"""
|
||||
if not pk_s or not pk_r or not sign:
|
||||
return False
|
||||
|
||||
rsa_pub_r = PublicKey.from_public_pem(pk_r)
|
||||
|
||||
if isinstance(sign, str):
|
||||
try:
|
||||
sign = base64.b16decode(sign.upper().encode())
|
||||
except Exception as e:
|
||||
logger.warning(f"ra: check_pubkey_valid b16decode sign failed, Exception={e}")
|
||||
sign = base64.b64decode(sign.encode())
|
||||
|
||||
msg = f"{pk_s}{timestamp}{service_name}".encode()
|
||||
if version:
|
||||
version_prefix = int(version).to_bytes(length=8, byteorder="big", signed=False)
|
||||
msg = version_prefix + msg
|
||||
|
||||
ret = rsa_pub_r.verify(sign, msg)
|
||||
|
||||
logger.debug(f"check_pubkey_valid: pk_s={pk_s}, pk_r={pk_r}, version={version}, timestamp={timestamp},"
|
||||
f"service_name={service_name}, msg={msg}, sign={sign}, ret={ret}")
|
||||
return ret
|
||||
|
||||
|
||||
def attest_server(token: Optional[str], config: RaConfig, nonce: str = None) -> Tuple[bool, str]:
|
||||
"""
|
||||
对数据接收方进行远程证明.
|
||||
:return (ra_status, pub_key)
|
||||
"""
|
||||
try:
|
||||
if config.bytedance_top_info:
|
||||
response = request_tca(config, nonce)
|
||||
else:
|
||||
response = attest_server_local(token, config, nonce)
|
||||
except ApiException as e:
|
||||
logger.error(f"attest_server error: config={config} ApiException={e}")
|
||||
raise error.ServiceError("RA", config.ra_url, None, "NULL")
|
||||
except Exception as e:
|
||||
logger.error(f"attest_server error: config={config} Exception={e}")
|
||||
raise error.ServiceError("RA", config.ra_url, None, "NULL")
|
||||
|
||||
if response is None:
|
||||
logger.error(f"RA Service error: config={config} response=NULL")
|
||||
raise error.ServiceError("RA", config.ra_url, None, "NULL")
|
||||
|
||||
if isinstance(response, requests.Response):
|
||||
if response.status_code != 200:
|
||||
logger.error(
|
||||
f"RA Service error: code={response.status_code}, content={response.content}, config={config}"
|
||||
)
|
||||
raise error.ServiceError(
|
||||
"RA", config.ra_url, None, f"code={response.status_code}"
|
||||
)
|
||||
else:
|
||||
response_text = response.text
|
||||
ra_res = json.loads(response_text).get("Result")
|
||||
if not ra_res:
|
||||
ra_res = json.loads(response_text).get("ResponseResult")
|
||||
else:
|
||||
ra_res = response
|
||||
|
||||
if not ra_res:
|
||||
logger.error(f"RA response is NULL, config={config}")
|
||||
return False, ""
|
||||
|
||||
try:
|
||||
attestation_status = False
|
||||
public_key_info = ""
|
||||
pub_r = ""
|
||||
|
||||
# 取第一个节点验证就可以
|
||||
for cluster_info, res_info in ra_res.items():
|
||||
# raw_evidence = res_info.get("evidence")
|
||||
# if raw_evidence:
|
||||
# tmp = json.loads(raw_evidence)
|
||||
# raw_evidence_json = json.dumps(tmp, indent=4)
|
||||
# logger.debug(f"get ra evidence:{raw_evidence_json}")
|
||||
if config.ra_key_negotiation:
|
||||
public_key_info = res_info.get("key_info").get("pub_key_info")
|
||||
else:
|
||||
public_key_info = ""
|
||||
|
||||
raw_token = res_info.get("token")
|
||||
if not raw_token:
|
||||
logger.critical("RA response token is NULL, conf={config}")
|
||||
return attestation_status, public_key_info
|
||||
else:
|
||||
evidence_info = res_info.get("evidence")
|
||||
evidence_info_json = json.loads(evidence_info)
|
||||
proof = evidence_info_json.get('proof')
|
||||
logger.debug(f"proof={proof}")
|
||||
|
||||
if config.ra_key_negotiation:
|
||||
key_info = res_info.get("key_info")
|
||||
pub_r = key_info.get("rar_pub_key")
|
||||
timestamp = key_info.get("timestamp")
|
||||
version = key_info.get("version")
|
||||
sign = key_info.get("rar_signature")
|
||||
service_name = config.ra_service_name.strip()
|
||||
|
||||
if sign:
|
||||
if not check_pubkey_valid(public_key_info, pub_r, timestamp, version, service_name, sign):
|
||||
logger.critical("verify sign failed")
|
||||
return attestation_status, public_key_info
|
||||
else:
|
||||
logger.info("ra: get pub_key, verify sign success")
|
||||
|
||||
attestation_status = verify_ra_result(raw_token, public_key_info, pub_r, proof, nonce)
|
||||
|
||||
break
|
||||
return attestation_status, public_key_info
|
||||
except Exception as e:
|
||||
logger.critical(f"Response is not JSON: conf={config}, Exception={e}")
|
||||
raise error.ServiceError("RA", config.ra_url, None, "not JSON") from e
|
||||
|
||||
|
||||
def verify_ra_result(raw_token, pub_key_info, pub_r, proof, nonce, challenge_report_data: str = None) -> bool:
|
||||
try:
|
||||
report_data = ""
|
||||
token_parts = raw_token.split(".")
|
||||
if len(token_parts) >= 2:
|
||||
token_data = token_parts[1]
|
||||
try:
|
||||
# 使用base64.urlsafe_b64decode进行URL安全模式的解码
|
||||
decoded_data = base64.urlsafe_b64decode(
|
||||
token_data + "=" * (4 - len(token_data) % 4)
|
||||
).decode("utf-8")
|
||||
token_dict = json.loads(decoded_data)
|
||||
report_data = token_dict.get('tdx').get('report_data')
|
||||
except Exception as e:
|
||||
logger.error(f"解码出现错误: {e}")
|
||||
raise Exception("decode token error")
|
||||
logger.info(f"in ra token,report_data={report_data}")
|
||||
|
||||
try:
|
||||
if challenge_report_data:
|
||||
if report_data != challenge_report_data:
|
||||
return False
|
||||
else:
|
||||
nonce_ret = verify_nonce(proof, report_data, nonce, pub_r) or \
|
||||
verify_nonce(proof, report_data, nonce, pub_key_info)
|
||||
if not nonce_ret:
|
||||
# logger.error("verify nonce failed")
|
||||
# return False
|
||||
logger.debug("verify nonce failed")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"verify nonce failed!!!,msg={str(e)}")
|
||||
return False
|
||||
|
||||
if not verify_jwt_token(raw_token):
|
||||
logger.warning("token verify failed")
|
||||
return False
|
||||
else:
|
||||
token_dict = {}
|
||||
token_parts = raw_token.split(".")
|
||||
if len(token_parts) >= 2:
|
||||
token_data = token_parts[1]
|
||||
try:
|
||||
# 使用base64.urlsafe_b64decode进行URL安全模式的解码
|
||||
decoded_data = base64.urlsafe_b64decode(
|
||||
token_data + "=" * (4 - len(token_data) % 4)
|
||||
).decode("utf-8")
|
||||
|
||||
token_dict = json.loads(decoded_data)
|
||||
except Exception as e:
|
||||
logger.critical(f"token 解码出现错误: {e}")
|
||||
return False
|
||||
|
||||
attestation_status = len(token_dict.get("policies_matched")) > 0
|
||||
return attestation_status
|
||||
except Exception as e:
|
||||
logger.critical("verify_ra_result error,msg={}".format(str(e)))
|
||||
raise Exception("verify_ra_result error,msg={}".format(str(e)))
|
||||
|
||||
|
||||
def attest_client(client_ra_config: dict, client_bytedance_top_info: str, nonce: str = None) -> bool:
|
||||
"""
|
||||
对数据发送方进行远程证明.
|
||||
"""
|
||||
ra_service_name: Optional[str] = client_ra_config.get("ra_service_name")
|
||||
ra_policy_id: Optional[str] = client_ra_config.get("ra_policy_id")
|
||||
ra_pods_info = client_ra_config.get("ra_pods_info")
|
||||
ra_key_negotiation: Optional[bool] = client_ra_config.get("ra_key_negotiation")
|
||||
|
||||
top_info = client_bytedance_top_info
|
||||
if top_info and isinstance(top_info, str):
|
||||
top_info = json.loads(top_info)
|
||||
|
||||
ra_url = client_ra_config.get("ra_url", "open.volcengineapi.com")
|
||||
ra_uid = client_ra_config.get("ra_uid")
|
||||
if ra_uid:
|
||||
headers = {"UID": ra_uid}
|
||||
else:
|
||||
headers = {}
|
||||
|
||||
if not nonce:
|
||||
nonce = generate_nonce()
|
||||
|
||||
if ra_service_name:
|
||||
body = {
|
||||
"Nonce": nonce,
|
||||
"PolicyID": ra_policy_id,
|
||||
"ServiceName": ra_service_name,
|
||||
"KeyNegotiation": ra_key_negotiation,
|
||||
"Token": True,
|
||||
}
|
||||
elif ra_pods_info:
|
||||
if isinstance(ra_pods_info, str):
|
||||
ra_pods_info = json.loads(ra_pods_info)
|
||||
body = {
|
||||
"Nonce": nonce,
|
||||
"PolicyID": ra_policy_id,
|
||||
"AttestedPods": [ra_pods_info],
|
||||
"KeyNegotiation": ra_key_negotiation,
|
||||
"Token": True,
|
||||
}
|
||||
else:
|
||||
raise error.ServiceError("RA", ra_service_name, None, "call TCA error")
|
||||
|
||||
response = request_bytedance_gateway(ra_url, body, headers, top_info)
|
||||
if not response:
|
||||
logger.error("Client RA error: response NULL")
|
||||
raise error.ServiceError("RA", ra_service_name, None, "NULL")
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(
|
||||
f"client ra error: ra_service_name={ra_service_name} code={response.status_code}"
|
||||
)
|
||||
raise error.ServiceError(
|
||||
"RA", ra_service_name, None, f"code={response.status_code}"
|
||||
)
|
||||
|
||||
try:
|
||||
ra_res = json.loads(response.text).get("Result")
|
||||
if not ra_res:
|
||||
logger.error(
|
||||
f"attest_client failed, verify_result is NULL, ra interface response={response.text}"
|
||||
)
|
||||
return False
|
||||
|
||||
attestation_status = False
|
||||
for cluster_info, res_info in ra_res.items():
|
||||
# raw_evidence = res_info.get("evidence")
|
||||
# tmp = json.loads(raw_evidence)
|
||||
# raw_evidence_json = json.dumps(tmp, indent=4)
|
||||
# logger.debug(f"get ra evidence:{raw_evidence_json}")
|
||||
|
||||
raw_token = res_info.get("token")
|
||||
if not raw_token:
|
||||
logger.critical("token is None")
|
||||
return False
|
||||
|
||||
if not verify_jwt_token(raw_token):
|
||||
logger.critical("token verify failed")
|
||||
return False
|
||||
|
||||
token_dict = {}
|
||||
token_parts = raw_token.split(".")
|
||||
if len(token_parts) >= 2:
|
||||
token_data = token_parts[1]
|
||||
try:
|
||||
# 使用base64.urlsafe_b64decode进行URL安全模式的解码
|
||||
decoded_data = base64.urlsafe_b64decode(
|
||||
token_data + "=" * (4 - len(token_data) % 4)
|
||||
).decode("utf-8")
|
||||
|
||||
token_dict = json.loads(decoded_data)
|
||||
except Exception as e:
|
||||
logger.critical(f"token 解码出现错误: {e}")
|
||||
return False
|
||||
|
||||
attestation_status = len(token_dict.get("policies_matched")) > 0
|
||||
break
|
||||
|
||||
return attestation_status
|
||||
except Exception as e:
|
||||
logger.critical(f"Response is not JSON: client_service_name={ra_service_name}")
|
||||
raise error.ServiceError("RA", ra_service_name, None, "not JSON") from e
|
||||
@@ -0,0 +1,734 @@
|
||||
"""
|
||||
数据接收方接口.
|
||||
"""
|
||||
|
||||
__all__ = ["Server"]
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from threading import Lock
|
||||
|
||||
import requests
|
||||
from typing_extensions import TYPE_CHECKING, Dict, Optional, Tuple, Union
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from _typeshed import GenericPath
|
||||
else:
|
||||
GenericPath = str
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
||||
|
||||
from bytedance.tks.client import TKSClient, TKSConfig, utc8_timestamp
|
||||
from . import error
|
||||
from .config import ServerConfig
|
||||
from .crypto import FileMode, PublicKey, ResponseKey, ServerSessionKey
|
||||
from .log import LogConfig, logger
|
||||
from .ra import (
|
||||
RaRequest,
|
||||
RaResponse,
|
||||
RaResponseMetadata,
|
||||
RaResponsePod,
|
||||
RaResponsePods,
|
||||
attest_client,
|
||||
validata_ra_request,
|
||||
)
|
||||
from .service import EpsClient, RasClient
|
||||
from .utils import RepeatTimer, request_bytedance_gateway, weak_repeat_timer
|
||||
|
||||
|
||||
@dataclass(eq=False)
|
||||
class Server:
|
||||
"""
|
||||
数据接收方接口.
|
||||
|
||||
本类的所有方法是线程安全的.
|
||||
"""
|
||||
|
||||
config: ServerConfig
|
||||
|
||||
ras_client: RasClient = field(init=False, repr=False)
|
||||
|
||||
eps_client: EpsClient = field(init=False, repr=False)
|
||||
|
||||
refresh_timer: Optional[RepeatTimer] = field(init=False, default=None)
|
||||
|
||||
session_key: Optional[ServerSessionKey] = field(init=False, default=None)
|
||||
|
||||
old_session_key: Optional[ServerSessionKey] = field(init=False, default=None)
|
||||
|
||||
session_key_lock: Lock = field(init=False, default_factory=Lock)
|
||||
"""
|
||||
线程安全: 修改 session_key 应该加锁.
|
||||
"""
|
||||
|
||||
root_key_info: Dict[str, PublicKey] = field(init=False, default_factory=dict)
|
||||
|
||||
def __post_init__(self):
|
||||
try:
|
||||
# 处理环境变量
|
||||
# need_evidence = os.environ.get("NEED_EVIDENCE")
|
||||
# if need_evidence != None:
|
||||
# if str(need_evidence).lower() == "false":
|
||||
# self.config.need_evidence = False
|
||||
#
|
||||
# attest_gpu = os.environ.get("ATTEST_GPU")
|
||||
# if attest_gpu != None:
|
||||
# if str(attest_gpu).lower() == "false":
|
||||
# self.config.attest_gpu = False
|
||||
self._init_log()
|
||||
|
||||
self.ras_client = RasClient(self.config)
|
||||
self.eps_client = EpsClient(self.config)
|
||||
|
||||
if self.config.root_key_config:
|
||||
self._root_key_parse()
|
||||
|
||||
if self.config.refresh_interval is not None:
|
||||
refresh_interval = self.config.refresh_interval
|
||||
|
||||
# 避免多实例集中访问TKS,所以这里加了一个随机数
|
||||
refresh_interval = refresh_interval + random.randint(1, 60)
|
||||
logger.info(
|
||||
f"start auto update key, refresh_interval={refresh_interval}"
|
||||
)
|
||||
|
||||
self.refresh_timer = timer = weak_repeat_timer(
|
||||
refresh_interval, self._auto_update
|
||||
)
|
||||
timer.start()
|
||||
|
||||
self._auto_update()
|
||||
except Exception as e:
|
||||
logger.critical(f"__post_init__ failed, e={e}")
|
||||
|
||||
def _auto_update(self):
|
||||
"""
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
self._auto_load_key()
|
||||
except Exception as e:
|
||||
logger.critical(f"load tks key failed, e={e}")
|
||||
|
||||
try:
|
||||
if self.config.client_ra_config is not None:
|
||||
if not self.attest_client():
|
||||
logger.error("attest client failed, Please check your config !")
|
||||
|
||||
except Exception as e:
|
||||
logger.critical(f"attest client failed, e={e}")
|
||||
|
||||
def _auto_load_key(self):
|
||||
try:
|
||||
logger.info("start load_key")
|
||||
if self.config.key_file:
|
||||
if not self.load_file_key():
|
||||
logger.error("load key from local key file failed, Please check your config !")
|
||||
elif self.config.key_type == "tks":
|
||||
if not self.load_tks_key():
|
||||
logger.error("load key from tks failed, Please check your config !")
|
||||
elif self.config.key_type == "random":
|
||||
if not self.generate_key():
|
||||
logger.error("generate key failed !")
|
||||
else:
|
||||
logger.critical("key config error, Both TKS keyid or key file is empty")
|
||||
|
||||
except Exception as e:
|
||||
logger.critical(f"load key failed, e={e}")
|
||||
|
||||
def _add_key(self, private_pem: Union[str, bytes], tks_version: Optional[int]):
|
||||
"""
|
||||
添加密钥对.
|
||||
"""
|
||||
# 判断是否需要更新密钥
|
||||
if isinstance(private_pem, str):
|
||||
new_md5 = hashlib.md5(private_pem.encode()).hexdigest()
|
||||
else:
|
||||
new_md5 = hashlib.md5(private_pem).hexdigest()
|
||||
|
||||
if self.session_key:
|
||||
old_md5 = self.session_key.pem_md5
|
||||
else:
|
||||
old_md5 = ""
|
||||
|
||||
if old_md5 != new_md5:
|
||||
expire_at = datetime.now(timezone.utc) + timedelta(
|
||||
seconds=self.config.valid_time
|
||||
)
|
||||
session_key = ServerSessionKey.load(
|
||||
private_pem, expire_at, tks_version, new_md5
|
||||
)
|
||||
with self.session_key_lock:
|
||||
self.old_session_key = self.session_key
|
||||
self.session_key = session_key
|
||||
|
||||
def _pcc_login(self) -> Dict[str, str]:
|
||||
"""
|
||||
访问pcc_controller模块,获取cookie
|
||||
:return:
|
||||
"""
|
||||
|
||||
if self.config.pcc_login_url is None:
|
||||
raise error.ConfigError("pcc_login_url", "PCC login url is missing")
|
||||
if self.config.pcc_password is None:
|
||||
raise error.ConfigError("pcc_password", "PCC password is missing")
|
||||
|
||||
ts = str(utc8_timestamp())
|
||||
req_body = {
|
||||
"Username": self.config.pcc_app_id,
|
||||
"Password": hashlib.md5(
|
||||
self.config.pcc_password.encode() + ts.encode()
|
||||
).hexdigest(),
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
self.config.pcc_login_url, headers={"TimeStamp": ts}, json=req_body
|
||||
)
|
||||
cookies = response.cookies
|
||||
jeddak_pcc_session = cookies.get("jeddak_pcc_session")
|
||||
assert jeddak_pcc_session is not None
|
||||
cookies = {"jeddak_pcc_session": jeddak_pcc_session}
|
||||
return cookies
|
||||
|
||||
def _get_pairkey_from_pem(self, private_key: bytes) -> Optional[Dict[str, str]]:
|
||||
"""
|
||||
从私钥pem中产生公私钥对 for peixuan
|
||||
:param private_key:
|
||||
:return:
|
||||
"""
|
||||
private_key_pem = serialization.load_pem_private_key(private_key, password=None)
|
||||
if not isinstance(private_key_pem, RSAPrivateKey):
|
||||
logger.error(f"not RSAPrivateKey, private_key={private_key}")
|
||||
return None
|
||||
|
||||
pub_key = private_key_pem.public_key()
|
||||
pub_key_str = pub_key.public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
).decode("utf-8")
|
||||
|
||||
ret = dict()
|
||||
ret["pub_key"] = pub_key_str
|
||||
ret["pri_key"] = private_key.decode()
|
||||
|
||||
return ret
|
||||
|
||||
def _get_tks_info_from_pcc(self, service_id: str, app_id: str):
|
||||
try:
|
||||
if not service_id:
|
||||
return None
|
||||
|
||||
top_conf = json.loads(self.config.bytedance_top_info)
|
||||
top_conf["action"] = "ListJscKey"
|
||||
|
||||
url = top_conf.get("url", "open.volcengineapi.com")
|
||||
body = {"ServiceId": service_id, "AccountId": app_id}
|
||||
response = request_bytedance_gateway(url, body, {}, top_conf)
|
||||
if not response or response.status_code != 200:
|
||||
logger.error("get tks info from pcc_controller failed")
|
||||
return None
|
||||
|
||||
ra_res = json.loads(response.text).get("Result")
|
||||
if not ra_res:
|
||||
logger.error(f"get key info from pcc_controller error: ServiceId={service_id}")
|
||||
return None
|
||||
|
||||
key_list = json.loads(response.text).get("Result").get("KeyList")
|
||||
if len(key_list) == 1:
|
||||
ring_id = key_list[0].get("RingID")
|
||||
key_id = key_list[0].get("KeyID")
|
||||
return ring_id, key_id
|
||||
else:
|
||||
logger.error(f"get key info from pcc_controller error: key_list={key_list}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.critical("_get_tks_info_from_pcc exception={}".format(e))
|
||||
return None
|
||||
|
||||
def load_tks_key(self) -> bool:
|
||||
"""
|
||||
从 TKS 获取或更新密钥对.
|
||||
"""
|
||||
logger.info("start load TKS key")
|
||||
|
||||
app_id, ring_id, key_id = (
|
||||
self.config.tks_app_id,
|
||||
self.config.tks_ring_id,
|
||||
self.config.tks_key_id,
|
||||
)
|
||||
|
||||
pcc_conf = None
|
||||
top_conf = None
|
||||
if self.config.bytedance_pcc_info:
|
||||
pcc_conf = json.loads(self.config.bytedance_pcc_info)
|
||||
elif self.config.bytedance_top_info:
|
||||
top_conf = json.loads(self.config.bytedance_top_info)
|
||||
|
||||
enable_tls = True
|
||||
if enable_tls_env := os.environ.get("enable_tls"):
|
||||
enable_tls = enable_tls_env.lower() == "true"
|
||||
elif enable_tls_env := os.environ.get("ENABLE_TLS"):
|
||||
enable_tls = enable_tls_env.lower() == "true"
|
||||
|
||||
cfg = TKSConfig(
|
||||
addr=self.config.tks_url,
|
||||
enable_tls=enable_tls,
|
||||
pcc_config=pcc_conf,
|
||||
top_config=top_conf,
|
||||
)
|
||||
|
||||
tks_client = TKSClient(app_id, cfg)
|
||||
|
||||
need_evidence_conf = self.config.need_evidence
|
||||
need_evidence = self.config.need_evidence
|
||||
need_evidence_env = os.environ.get("NEED_EVIDENCE")
|
||||
if need_evidence_env:
|
||||
need_evidence = not (need_evidence_env.lower() == "false")
|
||||
logger.info(
|
||||
f"need_evidence: conf={need_evidence_conf}, env={need_evidence_env}, use={need_evidence}"
|
||||
)
|
||||
|
||||
attest_gpu_conf = self.config.attest_gpu
|
||||
attest_gpu = attest_gpu_conf
|
||||
attest_gpu_env = os.environ.get("ATTEST_GPU")
|
||||
if attest_gpu_env:
|
||||
attest_gpu = not (attest_gpu_env.lower() == "false")
|
||||
logger.info(
|
||||
f"attest_gpu: conf={attest_gpu_conf}, env={attest_gpu_env}, use={attest_gpu}"
|
||||
)
|
||||
|
||||
max_try_count = 3
|
||||
for _ in range(max_try_count):
|
||||
try:
|
||||
service_id = self.config.service_id
|
||||
if not service_id:
|
||||
service_id = os.getenv("JPCC_JCK_SERVICE_ID")
|
||||
|
||||
key_info = self._get_tks_info_from_pcc(service_id, self.config.tks_app_id)
|
||||
if key_info:
|
||||
ring_id = key_info[0]
|
||||
key_id = key_info[1]
|
||||
|
||||
if self.config.pcc_login_url:
|
||||
cookies = self._pcc_login()
|
||||
private_key = tks_client.get_key(
|
||||
ring_id=ring_id,
|
||||
key_id=key_id,
|
||||
need_evidence=need_evidence,
|
||||
attest_gpu=attest_gpu,
|
||||
cookies=cookies,
|
||||
)
|
||||
else:
|
||||
private_key = tks_client.get_key(
|
||||
ring_id=ring_id,
|
||||
key_id=key_id,
|
||||
need_evidence=need_evidence,
|
||||
attest_gpu=attest_gpu,
|
||||
policy_id=self.config.tks_policy_id,
|
||||
tks_url=self.config.tks_url,
|
||||
tks_uid=self.config.tks_app_id,
|
||||
tks_service_name=self.config.tks_service_name,
|
||||
)
|
||||
|
||||
pair_key_info = self._get_pairkey_from_pem(private_key)
|
||||
logger.info("get key from TKS success")
|
||||
logger.debug(f"TKS key info={pair_key_info}")
|
||||
|
||||
self._add_key(private_key, None)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.critical(f"load_tks_key exception={e}")
|
||||
logger.critical("load_tks_key failed, try again ....")
|
||||
time.sleep(5)
|
||||
|
||||
return False
|
||||
|
||||
def load_file_key(self, path: Optional[GenericPath] = None):
|
||||
"""
|
||||
从文件读取 RSA 密钥.
|
||||
|
||||
Args:
|
||||
path: 密钥文件路径. 默认为配置中的 `key_file`.
|
||||
"""
|
||||
|
||||
path = path or self.config.key_file
|
||||
if path is None:
|
||||
raise error.ParamError("path", "Key file is not specified")
|
||||
|
||||
with open(path) as f:
|
||||
self.load_key(f.read())
|
||||
|
||||
return True
|
||||
|
||||
def load_key(self, private_key: Union[str, bytes]):
|
||||
"""
|
||||
导入 RSA 密钥.
|
||||
|
||||
Args:
|
||||
private_key: PEM 格式的 RSA 私钥.
|
||||
"""
|
||||
self._add_key(private_key, tks_version=None)
|
||||
|
||||
def generate_key(self):
|
||||
"""
|
||||
随机生成密钥对.
|
||||
"""
|
||||
expire_at = datetime.now(timezone.utc) + timedelta(
|
||||
seconds=self.config.valid_time
|
||||
)
|
||||
|
||||
session_key = ServerSessionKey.generate(expire_at)
|
||||
# 修改时加锁
|
||||
with self.session_key_lock:
|
||||
self.old_session_key = self.session_key
|
||||
self.session_key = session_key
|
||||
|
||||
return True
|
||||
|
||||
def remove_expired_keys(self):
|
||||
"""
|
||||
删除已过期的密钥对.
|
||||
"""
|
||||
# 修改时加锁
|
||||
with self.session_key_lock:
|
||||
if self.session_key is not None and self.session_key.is_expired():
|
||||
self.session_key = None
|
||||
|
||||
if self.old_session_key is not None and self.old_session_key.is_expired():
|
||||
self.old_session_key = None
|
||||
|
||||
def _get_public_key(self) -> str:
|
||||
"""
|
||||
获取已保存的最新的公钥.
|
||||
|
||||
Raises:
|
||||
KeyMissingError: 没有可用的密钥. 应通过 `load_tks_key`, `load_key`, 或
|
||||
`generate_key` 添加密钥.
|
||||
"""
|
||||
with self.session_key_lock:
|
||||
current_key = self.session_key
|
||||
|
||||
if not current_key:
|
||||
raise error.KeyMissingError() from None
|
||||
|
||||
return current_key.get_public_key()
|
||||
|
||||
def handle_ra_request(self, request: RaRequest) -> RaResponse:
|
||||
"""
|
||||
处理来自数据发送方的远程证明请求.
|
||||
应当在调用本函数前, 验证请求中 `token` 身份令牌的有效性.
|
||||
|
||||
Returns:
|
||||
远程证明响应. 可作为 JSON 响应给数据发送方.
|
||||
Raises:
|
||||
NetworkError: 访问证明生成服务 (EPS) 或远程证明服务 (RAS) 时出现问题.
|
||||
KeyMissingError: 没有可用的密钥. 应通过 `load_tks_key`, `load_key`, 或
|
||||
`generate_key` 添加密钥.
|
||||
"""
|
||||
validata_ra_request(request)
|
||||
|
||||
# quote = self.eps_client.get_quote(request["nonce"])
|
||||
# token = self.ras_client.get_attestation_evaluation("TDX", quote)
|
||||
|
||||
res_meta_data = RaResponseMetadata(Code=0, Message="success")
|
||||
res_result: RaResponsePods = {
|
||||
"pod_1": RaResponsePod(
|
||||
evidence="evidence",
|
||||
token="token",
|
||||
key_info={"pub_key_info": self._get_public_key()},
|
||||
),
|
||||
}
|
||||
|
||||
return RaResponse(ResponseMetadata=res_meta_data, ResponseResult=res_result)
|
||||
|
||||
def decrypt(
|
||||
self,
|
||||
message: Union[str, bytes],
|
||||
app_info: Optional[str] = None,
|
||||
sign: Optional[str] = None,
|
||||
) -> bytes:
|
||||
"""
|
||||
解密二进制数据.
|
||||
|
||||
Raises:
|
||||
DecryptionError: 加密消息不合法, 或者加密使用的密钥不在当前可用的密钥中.
|
||||
KeyMissingError: 没有可用的密钥. 应通过 `load_tks_key`, `load_key`, 或
|
||||
`generate_key` 添加密钥.
|
||||
"""
|
||||
if app_info and sign:
|
||||
if not self.verify_sign(app_info, message, sign):
|
||||
raise error.SignatureError("verify sign failed")
|
||||
|
||||
plaintext, _ = self.decrypt_with_response(message)
|
||||
return plaintext
|
||||
|
||||
def decrypt_with_response(
|
||||
self,
|
||||
message: Union[str, bytes],
|
||||
app_info: Optional[str] = None,
|
||||
sign: Optional[str] = None,
|
||||
) -> Tuple[bytes, ResponseKey]:
|
||||
"""
|
||||
解密二进制数据, 并获取用于加密响应的密钥.
|
||||
|
||||
Raises:
|
||||
DecryptionError: 加密消息不合法, 或者加密使用的密钥不在当前可用的密钥中.
|
||||
KeyMissingError: 没有可用的密钥. 应通过 `load_tks_key`, `load_key`, 或
|
||||
`generate_key` 添加密钥.
|
||||
"""
|
||||
last_error: Optional[Exception] = None
|
||||
|
||||
if app_info and sign:
|
||||
if not self.verify_sign(app_info, message, sign):
|
||||
raise error.SignatureError("verify sign failed")
|
||||
|
||||
with self.session_key_lock:
|
||||
current_key = self.session_key
|
||||
old_key = self.old_session_key
|
||||
|
||||
for key in [current_key, old_key]:
|
||||
if key is None:
|
||||
continue
|
||||
try:
|
||||
response_key = key.decrypt(message)
|
||||
return response_key
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
continue
|
||||
|
||||
if last_error:
|
||||
# 尝试更新一下密钥,因为上面的失败可能是本地密钥版本晚于客户端的密钥版本,等TKS支持密钥版本后可用密钥版本号来比较
|
||||
self._auto_load_key()
|
||||
if self.session_key is not None:
|
||||
try:
|
||||
response_key = self.session_key.decrypt(message)
|
||||
return response_key
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
|
||||
raise last_error or error.KeyMissingError()
|
||||
|
||||
def decrypt_file(
|
||||
self,
|
||||
metadata: Union[str, bytes],
|
||||
source_path: GenericPath,
|
||||
dest_path: GenericPath,
|
||||
mode: FileMode,
|
||||
app_info: Optional[str] = None,
|
||||
sign: Optional[str] = None,
|
||||
) -> ResponseKey:
|
||||
"""
|
||||
解密文件数据.
|
||||
|
||||
Returns:
|
||||
用于加密响应的密钥.
|
||||
"""
|
||||
if mode not in ["b", "t"]:
|
||||
raise error.ParamError("mode", "Mode should be 'b' or 't'")
|
||||
|
||||
if app_info and sign:
|
||||
# TODO: fill in message
|
||||
if not self.verify_sign(app_info, metadata, sign):
|
||||
raise error.SignatureError("verify sign failed")
|
||||
|
||||
with self.session_key_lock:
|
||||
current_key = self.session_key
|
||||
old_key = self.old_session_key
|
||||
|
||||
last_error: Optional[Exception] = None
|
||||
for key in [current_key, old_key]:
|
||||
if key is None:
|
||||
continue
|
||||
try:
|
||||
with open(source_path, f"r{mode}") as source, open(
|
||||
dest_path, f"w{mode}"
|
||||
) as dest:
|
||||
response_key = key.decrypt_stream(metadata, source, dest, mode)
|
||||
return response_key
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
continue
|
||||
|
||||
if last_error:
|
||||
# 尝试更新一下密钥,因为上面的失败可能是本地密钥版本晚于客户端的密钥版本,等TKS支持密钥版本后可用密钥版本号来比较
|
||||
self._auto_load_key()
|
||||
if self.session_key is not None:
|
||||
try:
|
||||
with open(source_path, f"r{mode}") as source, open(
|
||||
dest_path, f"w{mode}"
|
||||
) as dest:
|
||||
response_key = self.session_key.decrypt_stream(
|
||||
metadata, source, dest, mode
|
||||
)
|
||||
return response_key
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
|
||||
raise last_error or error.KeyMissingError()
|
||||
|
||||
def attest_client(self) -> bool:
|
||||
"""
|
||||
对客户端进行远程证明, 判断客户端是否可信.
|
||||
|
||||
Returns:
|
||||
True 为可信, False 为不可信.
|
||||
"""
|
||||
client_ra_config = self.config.client_ra_config
|
||||
if not client_ra_config:
|
||||
return True
|
||||
|
||||
client_ra_config = json.loads(client_ra_config)
|
||||
if not client_ra_config.get("open"):
|
||||
return True
|
||||
|
||||
logger.info("start attest client")
|
||||
nonce = ''.join(str(uuid.uuid4()).split('-'))[-16:]
|
||||
max_try_count = 3
|
||||
for _ in range(max_try_count):
|
||||
ret = attest_client(client_ra_config, self.config.bytedance_top_info, nonce)
|
||||
if ret:
|
||||
logger.info("attest client success")
|
||||
return True
|
||||
else:
|
||||
logger.info("attest client failed, try again...")
|
||||
|
||||
return False
|
||||
|
||||
def _root_key_parse(self):
|
||||
"""
|
||||
[{
|
||||
"app_info": "lenovo_xiao_tian",
|
||||
"pub_key": "******"
|
||||
}]
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
root_key_config = self.config.root_key_config
|
||||
if not root_key_config:
|
||||
return
|
||||
|
||||
if isinstance(root_key_config, str):
|
||||
root_key_config = json.loads(root_key_config)
|
||||
|
||||
for tmp_info in root_key_config:
|
||||
app_info = tmp_info.get("app_info")
|
||||
pub_key = tmp_info.get("pub_key")
|
||||
if pub_key:
|
||||
with open(pub_key) as f:
|
||||
public_pem = f.read()
|
||||
self.root_key_info[app_info] = PublicKey.from_public_pem(public_pem)
|
||||
else:
|
||||
# 从pcc_controller中读取
|
||||
public_pem = self._get_app_key_from_pcc(app_info)
|
||||
if public_pem:
|
||||
self.root_key_info[app_info] = PublicKey.from_public_pem(
|
||||
public_pem
|
||||
)
|
||||
else:
|
||||
logger.error("get app pubkey from pcc_controller failed")
|
||||
|
||||
logger.info("root_key_parse success")
|
||||
except Exception as e:
|
||||
logger.critical("root_key_parse exception={}".format(e))
|
||||
raise error.ConfigError("RootKey conf", "RootPubKey conf error")
|
||||
|
||||
def verify_sign(
|
||||
self, app_info: str, msg: Union[str, bytes], sign: Union[str, bytes]
|
||||
) -> bool:
|
||||
"""
|
||||
签名验证
|
||||
:param app_info: app_id+app_name
|
||||
:param msg:
|
||||
:param sign:
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
if not app_info or not msg or not sign:
|
||||
logger.info(f"verify_sign error, app_info={app_info}, sign={sign}")
|
||||
raise error.SignatureError("RSA signing failed") from None
|
||||
|
||||
if app_info not in self.root_key_info:
|
||||
logger.info(
|
||||
f"verify_sign error, can not find app_info in root key conf, app_info={app_info}"
|
||||
)
|
||||
raise error.SignatureError(
|
||||
f"can not find app_info={app_info}"
|
||||
) from None
|
||||
|
||||
if isinstance(sign, str):
|
||||
sign = base64.b64decode(sign.encode())
|
||||
|
||||
return self.root_key_info[app_info].verify(sign, msg)
|
||||
|
||||
except Exception as e:
|
||||
logger.critical("verify_sign exception={}".format(e))
|
||||
raise error.SignatureError("can not find app_info") from e
|
||||
|
||||
def _get_app_key_from_pcc(self, app_info):
|
||||
try:
|
||||
if not app_info:
|
||||
return None
|
||||
|
||||
top_conf = json.loads(self.config.bytedance_top_info)
|
||||
top_conf["action"] = "ListJscAppInfo"
|
||||
|
||||
url = top_conf.get("top_url", "open.volcengineapi.com")
|
||||
body = {"PageSize": -1}
|
||||
headers = {}
|
||||
response = request_bytedance_gateway(url, body, headers, top_conf)
|
||||
if not response or response.status_code != 200:
|
||||
logger.error("get app key from pcc_controller failed")
|
||||
return None
|
||||
|
||||
ra_res = json.loads(response.text).get("Result")
|
||||
if not ra_res:
|
||||
logger.error(
|
||||
f"get app key from pcc_controller error: app_info={app_info}"
|
||||
)
|
||||
return None
|
||||
|
||||
app_list = json.loads(response.text).get("Result").get("AppList")
|
||||
for app in app_list:
|
||||
if app_info == app.get("AppInfo"):
|
||||
return app.get("AppKey")
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.critical("get_app_key_from_pcc exception={}".format(e))
|
||||
return None
|
||||
|
||||
def _init_log(self):
|
||||
config = LogConfig()
|
||||
|
||||
log_config = dict()
|
||||
if self.config.log_config:
|
||||
log_config = json.loads(self.config.log_config)
|
||||
|
||||
if config_dir := log_config.get("dir"):
|
||||
config.dir = config_dir
|
||||
|
||||
if config_filename := log_config.get("filename"):
|
||||
config.filename = config_filename
|
||||
# else:
|
||||
# config.filename = "server.log"
|
||||
|
||||
if config_rotation := log_config.get("rotation"):
|
||||
config.rotation = config_rotation
|
||||
|
||||
if config_retention := log_config.get("retention"):
|
||||
config.retention = config_retention
|
||||
|
||||
if config_level := log_config.get("level"):
|
||||
config.level = config_level
|
||||
|
||||
logger.init_log_config(config)
|
||||
@@ -0,0 +1,16 @@
|
||||
__all__ = [
|
||||
"EpsClient",
|
||||
"EpsClient",
|
||||
"EpsConfig",
|
||||
"EpsConfig",
|
||||
"RasClient",
|
||||
"RasConfig",
|
||||
"TksAppOptions",
|
||||
"TksClient",
|
||||
"TksConfig",
|
||||
]
|
||||
|
||||
from .eps import EpsClient, EpsConfig
|
||||
from .ras import RasClient, RasConfig
|
||||
from .tks import TksClient, TksConfig
|
||||
from .tks_models import AppOptions as TksAppOptions
|
||||
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
证明生成服务 (Evidence Provision Service, EPS) 的客户端.
|
||||
"""
|
||||
|
||||
__all__ = ["EpsClient", "EpsConfig"]
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from typing_extensions import Any, Mapping
|
||||
|
||||
from .. import error
|
||||
from .eps_models import Evidence, ExtendRuntimeMeasurementParams, NonceData
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EpsConfig:
|
||||
"""访问证明生成服务所需的配置."""
|
||||
|
||||
eps_url: str = "http://localhost:8006"
|
||||
"""EPS 服务 Attestation Agent 的 HTTP 地址."""
|
||||
|
||||
|
||||
def aa_request(endpoint: str, params: Mapping[str, Any], config: EpsConfig) -> str:
|
||||
"""
|
||||
向 Attestation Agent 的指定端点发送 HTTP GET 请求.
|
||||
|
||||
Returns:
|
||||
响应体的字符串.
|
||||
"""
|
||||
import requests
|
||||
|
||||
try:
|
||||
response = requests.get(config.eps_url + endpoint, params)
|
||||
except Exception as e:
|
||||
logger.exception(f"Network error: service={config.eps_url} {endpoint=}")
|
||||
raise error.NetworkError("EPS", config.eps_url, endpoint) from e
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(
|
||||
f"Service error: service={config.eps_url} {endpoint=} code={response.status_code}"
|
||||
)
|
||||
raise error.ServiceError("EPS", config.eps_url, endpoint, f"code={response.status_code}")
|
||||
|
||||
content_type = response.headers.get("content-type", "")
|
||||
if not (content_type.startswith("application/octet-stream") or content_type.startswith("application/json")
|
||||
or content_type.startswith("text/plain")):
|
||||
logger.error(f"Service error: service={config.eps_url} {endpoint=} {content_type=}")
|
||||
raise error.ServiceError("EPS", config.eps_url, endpoint, f"{content_type=}")
|
||||
|
||||
try:
|
||||
return str(response.content, encoding="utf-8")
|
||||
except UnicodeDecodeError as e:
|
||||
logger.exception(f"Service error: service={config.eps_url} {endpoint=}")
|
||||
raise error.ServiceError("EPS", config.eps_url, endpoint, "cannot decode text") from e
|
||||
|
||||
|
||||
@dataclass(eq=False)
|
||||
class EpsClient:
|
||||
"""
|
||||
证明生成服务 (Evidence Provision Service, EPS) 的客户端.
|
||||
|
||||
本类的所有方法是线程安全的.
|
||||
"""
|
||||
|
||||
config: EpsConfig
|
||||
"""访问证明生成服务所需的配置."""
|
||||
|
||||
def get_token(self, token_type: str) -> str:
|
||||
"""
|
||||
访问 Attestation Agent 的 `/aa/token` 端点, 获取令牌.
|
||||
|
||||
Returns:
|
||||
令牌.
|
||||
"""
|
||||
return aa_request("/aa/token", {"token_type": token_type}, self.config)
|
||||
|
||||
def get_evidence_raw(self, nonce: str) -> str:
|
||||
"""
|
||||
访问 Attestation Agent 的 `/aa/evidence` 端点, 获取证明.
|
||||
|
||||
Returns:
|
||||
JSON 编码的证明. 包含 `cc_eventlog`, `quote`, `aa_eventlog` 字段.
|
||||
"""
|
||||
nonce_data = NonceData(runtime_data="", nonce=nonce, user_data="")
|
||||
|
||||
return aa_request("/aa/evidence", nonce_data, self.config)
|
||||
|
||||
def get_evidence(self, nonce: str) -> Evidence:
|
||||
"""
|
||||
访问 Attestation Agent 的 `/aa/evidence` 端点, 获取证明.
|
||||
|
||||
Returns:
|
||||
证明内容, 包含 `cc_eventlog`, `quote`, `aa_eventlog` 字段.
|
||||
"""
|
||||
import json
|
||||
|
||||
raw = self.get_evidence_raw(nonce)
|
||||
|
||||
return json.loads(raw)
|
||||
|
||||
def get_eventlog(self, nonce: str) -> str:
|
||||
"""
|
||||
访问 Attestation Agent 的 `/aa/eventlog` 端点, 获取事件日志.
|
||||
|
||||
Returns:
|
||||
Base64 编码的事件日志. 事件日志长度较大 (~1.5MB).
|
||||
"""
|
||||
nonce_data = NonceData(runtime_data="", nonce=nonce, user_data="")
|
||||
|
||||
return aa_request("/aa/eventlog", nonce_data, self.config)
|
||||
|
||||
def get_quote(self, nonce: str) -> str:
|
||||
"""
|
||||
访问 Attestation Agent 的 `/aa/quote` 端点, 获取度量值.
|
||||
|
||||
Returns:
|
||||
Base64 编码的度量值.
|
||||
"""
|
||||
nonce_data = NonceData(runtime_data="", nonce=nonce, user_data="")
|
||||
|
||||
return aa_request("/aa/quote", nonce_data, self.config)
|
||||
|
||||
def get_extended_runtime_measurement(self, params: ExtendRuntimeMeasurementParams) -> str:
|
||||
"""
|
||||
访问 Attestation Agent 的 `/aa/extend_runtime_measurement` 端点, 获取运行时度量事件日志.
|
||||
|
||||
Returns:
|
||||
明文的运行时度量事件日志.
|
||||
"""
|
||||
return aa_request("/aa/extend_runtime_measurement", params, self.config)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
证明生成服务 (EPS) 定义的数据类型.
|
||||
"""
|
||||
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
|
||||
|
||||
class NonceData(TypedDict):
|
||||
"""
|
||||
Attestation Agent 的 `/aa/evidence`, `/aa/quote`, `/aa/eventlog` 端点的请求体.
|
||||
目前 Attestation Agent 会将三个字段值直接连接.
|
||||
"""
|
||||
|
||||
runtime_data: str
|
||||
|
||||
nonce: str
|
||||
|
||||
user_data: str
|
||||
|
||||
|
||||
class ExtendRuntimeMeasurementParams(TypedDict):
|
||||
"""
|
||||
Attestation Agent 的 `/aa/extend_runtime_measurement` 端点的请求体.
|
||||
"""
|
||||
|
||||
domain: str
|
||||
|
||||
operation: str
|
||||
|
||||
context: str
|
||||
|
||||
register_index: NotRequired[int]
|
||||
|
||||
|
||||
class Evidence(TypedDict):
|
||||
"""
|
||||
Attestation Agent 的 `/aa/evidence` 端点的响应体.
|
||||
"""
|
||||
|
||||
cc_eventlog: str
|
||||
"""Base64 编码的事件日志. 事件日志长度较大 (~1.5MB)."""
|
||||
|
||||
quote: str
|
||||
"""Base64 编码的度量值."""
|
||||
|
||||
aa_eventlog: str
|
||||
"""明文的运行时度量事件日志."""
|
||||
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
远程证明服务 (Remote Attestation Service, RAS) 的客户端.
|
||||
"""
|
||||
|
||||
__all__ = ["RasClient", "RasConfig"]
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from typing_extensions import Any
|
||||
|
||||
from .. import error
|
||||
|
||||
# try:
|
||||
# import grpc
|
||||
#
|
||||
# from .ras_models import attestation_pb2, attestation_pb2_grpc
|
||||
# except ModuleNotFoundError:
|
||||
# grpc = None
|
||||
# attestation_pb2_grpc = None
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KNOWN_TEE = ["SEV", "SGX", "SNP", "TDX", "Sample", "AzSnpVtpm", "CSV", "CCA", "AzTdxVtpm"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class RasConfig:
|
||||
"""访问远程证明服务所需的配置."""
|
||||
|
||||
ras_address: str = "localhost:50004"
|
||||
"""远程证明服务的 GRPC 地址."""
|
||||
|
||||
|
||||
def encode_base64_no_pad(data: Any) -> str:
|
||||
return base64.urlsafe_b64encode(json.dumps(data).encode("utf-8")).decode("utf-8").rstrip("=")
|
||||
|
||||
|
||||
@dataclass(eq=False)
|
||||
class RasClient:
|
||||
"""
|
||||
远程证明服务 (Remote Attestation Service, RAS) 的客户端.
|
||||
|
||||
本类的所有方法是线程安全的.
|
||||
"""
|
||||
|
||||
config: RasConfig
|
||||
"""访问远程证明服务所需的配置."""
|
||||
|
||||
def get_attestation_evaluation(self, tee: str, quote: str) -> str:
|
||||
"""
|
||||
访问远程证明服务的 AttestationRequest 端点来验证度量值.
|
||||
|
||||
Args:
|
||||
tee: 所属 TEE 环境类型.
|
||||
quote: Base64 编码的度量值. 参考 `EpsClient#get_quote()`.
|
||||
Returns:
|
||||
Base64 编码的验证令牌.
|
||||
"""
|
||||
pass
|
||||
|
||||
# if grpc is None or attestation_pb2_grpc is None:
|
||||
# raise error.ConfigError(self, "RAS depends on grpcio which is not installed")
|
||||
#
|
||||
# # An early sanity check.
|
||||
# if tee not in KNOWN_TEE:
|
||||
# raise error.ParamError("tee", f"Unknown TEE type '{tee}'")
|
||||
#
|
||||
# encoded_evidence = encode_base64_no_pad({"quote": quote})
|
||||
# request = attestation_pb2.AttestationRequest( # pyright: ignore
|
||||
# tee=tee, evidence=encoded_evidence, policy_ids=[]
|
||||
# )
|
||||
#
|
||||
# try:
|
||||
# channel = grpc.insecure_channel(self.config.ras_address)
|
||||
# client = attestation_pb2_grpc.AttestationServiceStub(channel)
|
||||
#
|
||||
# response = client.AttestationEvaluate(request)
|
||||
# except Exception as e:
|
||||
# logger.exception(
|
||||
# f"Network error service={self.config.ras_address} endpoint=AttestationEvaluate"
|
||||
# )
|
||||
# raise error.NetworkError("RAS", self.config.ras_address, "AttestationEvaluate") from e
|
||||
#
|
||||
# return response.attestation_token
|
||||
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
密钥管理服务 (Trusted Key Service, TKS) 的客户端.
|
||||
TCA采用背调模式
|
||||
"""
|
||||
|
||||
__all__ = ["TksClient", "TksConfig"]
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing_extensions import Any, Optional, Tuple
|
||||
|
||||
from .eps import EpsClient
|
||||
from .ras import RasClient
|
||||
|
||||
|
||||
@dataclass
|
||||
class TksConfig:
|
||||
"""访问密钥管理服务所需的配置."""
|
||||
|
||||
tks_url: str = "http://localhost:6789"
|
||||
"""TKS 服务的 HTTP 地址."""
|
||||
|
||||
tks_timezone: float = 8.0
|
||||
"""TKS 服务使用的时区. 一般不需要修改."""
|
||||
|
||||
tks_enable_server_auth: bool = True
|
||||
"""
|
||||
是否对 TKS 服务提供的证明报告进行验证.
|
||||
默认为是.
|
||||
"""
|
||||
|
||||
tks_enable_bi_auth: bool = True
|
||||
"""
|
||||
是否将客户端的证明报告发送给 TKS 服务.
|
||||
默认为是.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(eq=False)
|
||||
class TksClient:
|
||||
"""
|
||||
密钥管理服务 (Trusted Key Service, TKS) 的客户端.
|
||||
|
||||
本类的所有方法是线程安全的.
|
||||
"""
|
||||
|
||||
config: TksConfig
|
||||
"""访问密钥管理服务所需的配置."""
|
||||
|
||||
ras_client: Optional[RasClient] = field(repr=False, default=None)
|
||||
|
||||
eps_client: Optional[EpsClient] = field(repr=False, default=None)
|
||||
@@ -0,0 +1,334 @@
|
||||
"""
|
||||
密钥管理服务 (TKS) 定义的数据类型.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from typing_extensions import Generic, Literal, NotRequired, Optional, TypedDict, TypeVar
|
||||
|
||||
|
||||
class TksAuthHeaders(TypedDict):
|
||||
AppID: str
|
||||
"""请求TKS的客户端的身份ID"""
|
||||
|
||||
Timestamp: str
|
||||
"""
|
||||
Unix时间戳(UTC+8)
|
||||
Example: 1722253648
|
||||
"""
|
||||
|
||||
Signature: NotRequired[str]
|
||||
"""
|
||||
对请求Body的签名,仅当账号注册时的认证方式带有cert时(cert | cert+zti)
|
||||
"""
|
||||
|
||||
Token: NotRequired[str]
|
||||
"""
|
||||
认证Token,仅当账号注册时的认证方式带有zti时(zti | cert+zti)
|
||||
"""
|
||||
|
||||
|
||||
class ResponseError(TypedDict):
|
||||
Code: int
|
||||
"""
|
||||
错误码
|
||||
Example: 69905
|
||||
"""
|
||||
|
||||
Message: str
|
||||
"""
|
||||
请求处理错误原因,正常请求为空
|
||||
Example: error occured while processing your request
|
||||
"""
|
||||
|
||||
|
||||
class ResponseMeta(TypedDict):
|
||||
Method: str
|
||||
"""
|
||||
请求方法
|
||||
Example: KeyGet
|
||||
"""
|
||||
|
||||
Error: NotRequired[ResponseError]
|
||||
"""错误信息,在请求正常响应情况下为空"""
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class TksResponse(Generic[T], TypedDict):
|
||||
ResponseMetadata: ResponseMeta
|
||||
"""描述响应状态的元信息"""
|
||||
|
||||
Result: NotRequired[T]
|
||||
"""返回结果,当请求出错时为空"""
|
||||
|
||||
|
||||
class ClientChallenge(TypedDict):
|
||||
NonceDown: str
|
||||
"""
|
||||
经过hex编码后,长度为256 bits的随机挑战值
|
||||
Example: 5779d18b2b8718cef025282df2a313f154ad4f7b862b2948ecf6cf401de17634
|
||||
"""
|
||||
|
||||
RxpirationTime: int
|
||||
"""
|
||||
时区为UTC+8的Unix时间戳,表示随机挑战的过期时间
|
||||
Example: 1721972547
|
||||
"""
|
||||
|
||||
ChallSig: int
|
||||
"""
|
||||
经过Based64编码后的,TKS针对challenge自身的签名。使用TKS RA阶段提供的cert对应的私钥签名(ECDSA-SECP384R1)
|
||||
Example: yk63SiKLaBB7E0OVQs3Flw8NsTRNEYJIUos8s9F14sg=
|
||||
"""
|
||||
|
||||
|
||||
class SecurityAttestation(TypedDict):
|
||||
NonceUp: str
|
||||
"""
|
||||
随机字符串,针对TSK进行远程证明的挑战值(32随机bytes,经过Hex编码)
|
||||
Example: a314b43502077cd89d21378b29954207aaf5b3848d4d7fa9f16682ba1f6f3317
|
||||
"""
|
||||
|
||||
BiAuth: NotRequired[bool]
|
||||
"""
|
||||
是否要求TKS挑战请求方
|
||||
Example: true
|
||||
"""
|
||||
|
||||
|
||||
class AttestationResult(TypedDict):
|
||||
RAType: str
|
||||
"""
|
||||
TKS提供的远程证明报告的类型
|
||||
Example: sgx | tdx
|
||||
"""
|
||||
|
||||
Report: str
|
||||
"""
|
||||
经过base64编码后的远程证明报告,需要根据raType来进行验证
|
||||
Example: MTIzNA==
|
||||
"""
|
||||
|
||||
Cert: str
|
||||
"""
|
||||
经过base64编码后的,PEM形式的证书(ECDH-SECP384R1-SHA256)
|
||||
Example: LS0tLS1CRUdJTiBQVU==
|
||||
"""
|
||||
|
||||
DHParam: str
|
||||
"""
|
||||
经过base64编码后的,密钥协商公共参数(ECDH-SECP384R1-SHA256)
|
||||
Example: LS0tLS1CRUdJTiBQVUJMSUMg
|
||||
"""
|
||||
|
||||
Challenge: NotRequired[ClientChallenge]
|
||||
"""当biAuth参数为true时,返回的针对请求方的挑战内容"""
|
||||
|
||||
|
||||
class KMSInfo(TypedDict):
|
||||
Type: Literal["tks"]
|
||||
"""托管密钥服务的类型,当前仅支持tks"""
|
||||
|
||||
|
||||
class RAEvidence(TypedDict):
|
||||
TEEType: str
|
||||
"""指定请求方的TEE类型"""
|
||||
|
||||
Report: str
|
||||
"""
|
||||
经过Base64编码的,请求方TEE生成的远程证明报告,需要包含clientChall中提供的随机挑战nonceDown
|
||||
Example: OUeRWgdMoQahtmzTA0/36qA5Lh8wp8nIK/ySOe0wQeA=
|
||||
"""
|
||||
|
||||
|
||||
class ZTIEvidence(TypedDict):
|
||||
Token: str
|
||||
"""
|
||||
请求方的ZTI JWT Token
|
||||
"""
|
||||
|
||||
|
||||
class CertEvidence(TypedDict):
|
||||
Signature: str
|
||||
"""
|
||||
经过Base64编码的,请求方的私钥签名的nonceDown
|
||||
Example: RZeo3OjJM/Gam+aiun+hNzD1mMnnJmpiWckLnVfqqBE=
|
||||
"""
|
||||
|
||||
|
||||
class KeyIndex(TypedDict):
|
||||
AppID: str
|
||||
"""
|
||||
指定要获取的密钥Owner的AppID (PSM)
|
||||
Example: security.research.tks_owner
|
||||
"""
|
||||
|
||||
RingID: str
|
||||
"""
|
||||
密钥环ID
|
||||
Example: 640a7c2a-c061-4964-b7d8-951e9272da21
|
||||
"""
|
||||
|
||||
KeyID: str
|
||||
"""
|
||||
指定要获取的密钥的ID
|
||||
Example: d880cf63-7c3e-45e1-83e5-ab62ba3d3441
|
||||
"""
|
||||
|
||||
|
||||
class KeyIndexWithVersion(KeyIndex):
|
||||
Version: int
|
||||
"""
|
||||
返回Key的版本,从1开始
|
||||
Example: 1
|
||||
"""
|
||||
|
||||
|
||||
class ChallengeResponse(TypedDict):
|
||||
ClientChall: NotRequired[ClientChallenge]
|
||||
"""
|
||||
在远程阶段获取到的,由TKS返回的挑战信息。
|
||||
"""
|
||||
|
||||
RAEvidence: NotRequired[RAEvidence]
|
||||
"""
|
||||
提供基于TEE远程证明相关的证明信息,当访问控制策略中包含RA类型时需要提供
|
||||
"""
|
||||
|
||||
ZTIEvidence: NotRequired[ZTIEvidence]
|
||||
"""
|
||||
提供基于ZTI相关的证明信息,当访问控制策略中包含ZTI类型时需要提供
|
||||
"""
|
||||
|
||||
CertEvidence: NotRequired[CertEvidence]
|
||||
"""
|
||||
提供基于证书相关的证明信息,当访问控制策略中包含Cert类型时需要提供
|
||||
"""
|
||||
|
||||
|
||||
class KeyExportation(KeyIndex, ChallengeResponse):
|
||||
DHParam: str
|
||||
"""
|
||||
经过Base64编码的,请求方的DH公共参数
|
||||
Example: KGIW9H8TCIi+ZNwikN8cc4V2cTNzOi/VfLNF7oYBwvE=
|
||||
"""
|
||||
|
||||
KMSInfo: NotRequired[KMSInfo]
|
||||
"""
|
||||
描述密钥所在的KMS位置,当前仅支持TKS
|
||||
"""
|
||||
|
||||
Passport: NotRequired[str]
|
||||
"""
|
||||
JWT Token。由TKS签发的访问Token,获取Token的参与方在验证通过可以直接获取密钥。
|
||||
"""
|
||||
|
||||
|
||||
class ExportationResult(KeyIndexWithVersion):
|
||||
Key: str
|
||||
"""
|
||||
经过Base64编码后的Key的密文,通过请求参数密钥协商出来的密钥加密(AES-256-GCM)
|
||||
Example: aq1xcWeuyyKTDY8kXqgqk9TmBjhqFhwZB7+p0GgK60I=
|
||||
"""
|
||||
|
||||
|
||||
class KeyCreation(TypedDict):
|
||||
RingID: str
|
||||
"""
|
||||
密钥环ID(为空情况下,放置到用户的一个default密钥环下)
|
||||
Example: 640a7c2a-c061-4964-b7d8-951e9272da21
|
||||
"""
|
||||
|
||||
KeyID: NotRequired[str]
|
||||
"""
|
||||
密钥ID,置空则由TKS随机分配一个UUID
|
||||
Example: d880cf63-7c3e-45e1-83e5-ab62ba3d3441
|
||||
"""
|
||||
|
||||
KeyName: str
|
||||
"""
|
||||
密钥名称
|
||||
Example: test-demo-key
|
||||
"""
|
||||
|
||||
KeyAlgo: str
|
||||
"""
|
||||
密钥算法(当前仅支持基于对称加密算法)
|
||||
Example: SYMMTRIC_128 | SYMMTRIC_256
|
||||
"""
|
||||
|
||||
Source: Literal["external", "internal"]
|
||||
"""
|
||||
密钥来源
|
||||
- external: 外部导入
|
||||
- internal: 由TKS生成
|
||||
Example: internal
|
||||
"""
|
||||
|
||||
Description: NotRequired[str]
|
||||
"""
|
||||
密钥描述信息
|
||||
Example: Key for test
|
||||
"""
|
||||
|
||||
Lifetime: NotRequired[int]
|
||||
"""
|
||||
密钥生命周期,Unix时间戳(UTC+8)。默认情况为-1,表示无限期
|
||||
Example: 1722254231
|
||||
"""
|
||||
|
||||
KMSInfo: NotRequired[KMSInfo]
|
||||
"""
|
||||
描述密钥托管位置
|
||||
"""
|
||||
|
||||
ACLRules: NotRequired[str]
|
||||
"""
|
||||
描述密钥的访问控制策略,JSON字符串,默认情况下允许所有人访问,即
|
||||
{"default": "accept","rules": [],"range": "keyID","issueToken": "false"}
|
||||
Example: {"type": "RA", "tee": "tdx", "measurements":
|
||||
{"tdx.rtmr0": "561bf3eee22f", "tdx.rtmr1": "d4247b6bd6eb"}}
|
||||
"""
|
||||
|
||||
|
||||
class KeyImportation(TypedDict):
|
||||
RingID: str
|
||||
"""
|
||||
密钥环ID(为空情况下,放置到用户的一个default密钥环下
|
||||
Example: 640a7c2a-c061-4964-b7d8-951e9272da21
|
||||
"""
|
||||
|
||||
KeyID: str
|
||||
"""
|
||||
密钥ID,置空则由TKS随机分配一个UUID
|
||||
Example: d880cf63-7c3e-45e1-83e5-ab62ba3d3441
|
||||
"""
|
||||
|
||||
Key: str
|
||||
"""
|
||||
密钥密文。经过Base64编码后的密钥密文,加密密钥应通过AES-GCM-256加密,
|
||||
AES密钥基于TKS公共参数协商得出(ECDH-SECP384R1-SHA256)。可为空,置空后密钥状态为empty
|
||||
Example: wjBXVnzCFOc/NhQ6vXZhllGa1Kirs9VvE+GVSuYCEZM=
|
||||
"""
|
||||
|
||||
DHParam: str
|
||||
"""
|
||||
经过Base64编码后的请求方侧的密钥协商参数,帮助TKS派生出通信密钥(ECDH-SECP384R1-SHA256)
|
||||
Example: wjBXVnzCFOc/NhQ6vXZhllGa1Kirs9VvE+GVSuYCEZM=
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppOptions:
|
||||
"""特定于 App 的认证选项."""
|
||||
|
||||
id: str
|
||||
"""App ID."""
|
||||
|
||||
signing_key: Optional[str] = None
|
||||
"""PEM 格式的 App 私钥. 当 TKS 中 authType 设置为 cert 时使用."""
|
||||
|
||||
zti_token: Optional[str] = None
|
||||
"""ZTI 令牌. 当 TKS 中 authType 设置为 zti 时使用."""
|
||||
@@ -0,0 +1,469 @@
|
||||
__all__ = [
|
||||
"RepeatTimer",
|
||||
"WeakMethod",
|
||||
"weak_repeat_timer",
|
||||
"request_bytedance_gateway",
|
||||
]
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import urllib.parse
|
||||
import weakref
|
||||
from datetime import datetime, timezone
|
||||
from threading import Timer
|
||||
from typing import Dict
|
||||
|
||||
import requests
|
||||
import volcenginesdkcore
|
||||
import volcenginesdksts
|
||||
from typing_extensions import (
|
||||
Any,
|
||||
Callable,
|
||||
Concatenate,
|
||||
Generic,
|
||||
Iterable,
|
||||
Mapping,
|
||||
Optional,
|
||||
ParamSpec,
|
||||
Protocol,
|
||||
TypeVar,
|
||||
)
|
||||
from volcenginesdkcore.rest import ApiException
|
||||
|
||||
from . import error
|
||||
from .log import logger
|
||||
|
||||
|
||||
class RepeatTimer(Timer):
|
||||
"""
|
||||
定时重复执行函数.
|
||||
"""
|
||||
|
||||
def run(self):
|
||||
while not self.finished.wait(self.interval):
|
||||
self.function(*self.args, **self.kwargs)
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
P = ParamSpec("P")
|
||||
R = TypeVar("R")
|
||||
R_co = TypeVar("R_co", covariant=True)
|
||||
|
||||
|
||||
class MethodProtocol(Protocol[T, P, R]):
|
||||
__self__: T
|
||||
|
||||
__func__: Callable[Concatenate[T, P], R]
|
||||
|
||||
__call__: Callable[P, R]
|
||||
|
||||
|
||||
class WeakMethod(Generic[T, P, R_co]):
|
||||
"""
|
||||
对方法 (bound method) 的弱引用.
|
||||
"""
|
||||
|
||||
# instance: weakref.ReferenceType[T]
|
||||
# func: weakref.ReferenceType[Callable[Concatenate[T, P], R_co]]
|
||||
|
||||
def __init__(self, method: Callable[P, R_co]):
|
||||
# Type checkers do not differentiate methods from callables, so
|
||||
# we have to ignore the casting error
|
||||
_method: MethodProtocol[T, P, R_co] = method # pyright: ignore
|
||||
|
||||
self.instance = weakref.ref(_method.__self__)
|
||||
self.func = weakref.ref(_method.__func__)
|
||||
|
||||
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Optional[R_co]:
|
||||
instance = self.instance()
|
||||
func = self.func()
|
||||
|
||||
if instance is None or func is None:
|
||||
logger.info("Weak method called on freed object")
|
||||
return
|
||||
|
||||
return func(instance, *args, **kwargs)
|
||||
|
||||
|
||||
def weak_repeat_timer(
|
||||
interval: float,
|
||||
method: Callable[P, None],
|
||||
args: Optional[Iterable[Any]] = None,
|
||||
kwargs: Optional[Mapping[str, Any]] = None,
|
||||
) -> RepeatTimer:
|
||||
"""
|
||||
定时重复执行对象上的方法. 在对象被垃圾回收时自动结束线程.
|
||||
"""
|
||||
# Due to limitations of 'ParamSpec', we cannot annotate 'args' and 'kwargs' precisely
|
||||
timer = RepeatTimer(interval, WeakMethod(method), args, kwargs)
|
||||
weakref.finalize(method.__self__, timer.cancel) # pyright: ignore
|
||||
|
||||
return timer
|
||||
|
||||
|
||||
class TopRequester:
|
||||
def __init__(
|
||||
self,
|
||||
ak,
|
||||
sk,
|
||||
token=None,
|
||||
host="open.volcengineapi.com",
|
||||
region="cn-beijing",
|
||||
use_tls=True,
|
||||
service="pcc",
|
||||
version="2024-12-24",
|
||||
debug=False,
|
||||
):
|
||||
volc_config = volcenginesdkcore.Configuration()
|
||||
volc_config.host = host
|
||||
volc_config.ak = ak
|
||||
volc_config.sk = sk
|
||||
volc_config.session_token = token
|
||||
volc_config.region = region
|
||||
volc_config.debug = debug
|
||||
if use_tls:
|
||||
volc_config.scheme = "https"
|
||||
else:
|
||||
volc_config.scheme = "http"
|
||||
self._api_client = volcenginesdkcore.ApiClient(configuration=volc_config)
|
||||
self._service = service
|
||||
self._version = version
|
||||
|
||||
def _construct_resource_path(
|
||||
self,
|
||||
action: str,
|
||||
version: str,
|
||||
service: str,
|
||||
method: str = "post",
|
||||
content_type: str = "application_json",
|
||||
) -> str:
|
||||
return f"/{action}/{version}/{service}/{method.lower()}/{content_type}/"
|
||||
|
||||
def request(
|
||||
self,
|
||||
method,
|
||||
query,
|
||||
header,
|
||||
param,
|
||||
action,
|
||||
body,
|
||||
service=None,
|
||||
version=None,
|
||||
content_type="application/json",
|
||||
):
|
||||
resp = self._api_client.call_api(
|
||||
self._construct_resource_path(
|
||||
action=action,
|
||||
version=self._version if not version else version,
|
||||
service=self._service if not service else service,
|
||||
method=method,
|
||||
content_type=content_type.replace("/", "_"),
|
||||
),
|
||||
method,
|
||||
body=body,
|
||||
auth_settings=["volcengineSign"],
|
||||
header_params={
|
||||
"Accept": "application/json",
|
||||
"Content-Type": content_type,
|
||||
**header,
|
||||
},
|
||||
path_params=param,
|
||||
query_params=query,
|
||||
post_params=[],
|
||||
files={},
|
||||
response_type=bytes,
|
||||
collection_formats={},
|
||||
_return_http_data_only=False,
|
||||
_preload_content=True,
|
||||
)
|
||||
|
||||
status, headers, data = resp[1], resp[2], resp[0]
|
||||
logger.info(f"Top request response,status={status}, headers: {headers}")
|
||||
|
||||
return data
|
||||
|
||||
def request_with_assume_role(
|
||||
self,
|
||||
method,
|
||||
query,
|
||||
header,
|
||||
param,
|
||||
service,
|
||||
action,
|
||||
body,
|
||||
version,
|
||||
target_uid,
|
||||
target_resource=None,
|
||||
region="cn-beijing",
|
||||
content_type="application/json",
|
||||
use_tls=True,
|
||||
host="open.volcengineapi.com",
|
||||
):
|
||||
data = {
|
||||
"TargetAccount": target_uid,
|
||||
"TargetResource": os.getenv("PCC_SERVICE", "pcc")
|
||||
if not target_resource
|
||||
else target_resource,
|
||||
}
|
||||
access_info = self.request("POST", [], {}, {}, "UpdateUserAccessInfo", data)
|
||||
ak = access_info["Ak"]
|
||||
sk = access_info["Sk"]
|
||||
sts_token = access_info["StsToken"]
|
||||
|
||||
customer_requester = TopRequester(
|
||||
ak=ak, sk=sk, token=sts_token, region=region, use_tls=use_tls, host=host
|
||||
)
|
||||
return customer_requester.request(
|
||||
method, query, header, param, action, body, service, version, content_type
|
||||
)
|
||||
|
||||
|
||||
def _hmac_sha256(key: bytes, content: str):
|
||||
return hmac.new(key, content.encode(), hashlib.sha256).digest()
|
||||
|
||||
|
||||
def _hash_sha256(content: str):
|
||||
return hashlib.sha256(content.encode()).hexdigest()
|
||||
|
||||
|
||||
def _format_query(params: Dict[str, str]) -> str:
|
||||
return "&".join(
|
||||
urllib.parse.quote(key, safe="-_.~")
|
||||
+ "="
|
||||
+ urllib.parse.quote(value, safe="-_.~")
|
||||
for key, value in params.items()
|
||||
)
|
||||
|
||||
|
||||
def top_request(
|
||||
ra_url: str,
|
||||
body: Any,
|
||||
additional_headers: Optional[Dict[str, str]],
|
||||
top_config: Dict[str, str],
|
||||
) -> requests.Response:
|
||||
top_ak = top_config["ak"]
|
||||
top_sk = top_config["sk"]
|
||||
top_service = top_config["service"]
|
||||
top_region = top_config.get("region", "cn-beijing")
|
||||
top_method = top_config.get("method", "POST").upper()
|
||||
top_action = top_config.get("action", "GetAttestationBackend")
|
||||
top_version = top_config.get("version", "2024-12-24")
|
||||
use_tls: bool = top_config.get("use_tls", True) # type: ignore
|
||||
sts_token = top_config.get("sts_token", None)
|
||||
target_uid = top_config.get("target_uid", None)
|
||||
target_resource = top_config.get("target_resource", "pcc")
|
||||
|
||||
if ra_url:
|
||||
top_host = ra_url
|
||||
else:
|
||||
top_host = top_config.get("url", "open.volcengineapi.com")
|
||||
|
||||
requester = TopRequester(
|
||||
ak=top_ak,
|
||||
sk=top_sk,
|
||||
token=sts_token,
|
||||
region=top_region,
|
||||
use_tls=use_tls,
|
||||
host=top_host,
|
||||
service=top_service,
|
||||
version=top_version,
|
||||
)
|
||||
|
||||
resp = None
|
||||
if not target_uid:
|
||||
try:
|
||||
resp = requester.request(
|
||||
top_method, [], additional_headers, {}, top_action, body
|
||||
)
|
||||
except ApiException as e:
|
||||
logger.error(
|
||||
"status: {}, reason: {}, body: {}, headers: {}".format(
|
||||
e.status, e.reason, e.body, e.headers
|
||||
)
|
||||
)
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error("request error: {}".format(e))
|
||||
raise e
|
||||
else:
|
||||
try:
|
||||
resp = requester.request_with_assume_role(
|
||||
top_method,
|
||||
[],
|
||||
additional_headers,
|
||||
{},
|
||||
top_service,
|
||||
top_action,
|
||||
body,
|
||||
top_version,
|
||||
target_uid,
|
||||
target_resource,
|
||||
top_region,
|
||||
use_tls=use_tls,
|
||||
host=top_host
|
||||
)
|
||||
except ApiException as e:
|
||||
logger.error(
|
||||
"status: {}, reason: {}, body: {}, headers: {}".format(
|
||||
e.status, e.reason, e.body, e.headers
|
||||
)
|
||||
)
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error("request error: {}".format(e))
|
||||
raise e
|
||||
|
||||
return resp
|
||||
|
||||
|
||||
def get_top_access_key(top_config: Dict[str, str]) -> tuple:
|
||||
try:
|
||||
configuration = volcenginesdkcore.Configuration()
|
||||
configuration.ak = top_config.get("ak")
|
||||
configuration.sk = top_config.get("sk")
|
||||
configuration.region = top_config.get("region", "cn-beijing")
|
||||
|
||||
duration_seconds = int(top_config.get("duration_seconds", "3600"))
|
||||
role_session_name = top_config.get("role_session_name", "temp_")
|
||||
role_trn = top_config.get("aicc_saas_trn")
|
||||
|
||||
volcenginesdkcore.Configuration.set_default(configuration)
|
||||
|
||||
api_instance = volcenginesdksts.STSApi()
|
||||
assume_role_request = volcenginesdksts.AssumeRoleRequest(
|
||||
duration_seconds=duration_seconds,
|
||||
role_session_name=role_session_name,
|
||||
role_trn=role_trn,
|
||||
)
|
||||
|
||||
ret = api_instance.assume_role(assume_role_request)
|
||||
if ret and ret.credentials:
|
||||
ak = ret.credentials.access_key_id
|
||||
sk = ret.credentials.secret_access_key
|
||||
session_token = ret.credentials.session_token
|
||||
|
||||
return ak, sk, session_token
|
||||
else:
|
||||
return None, None, None
|
||||
|
||||
except Exception as e:
|
||||
logger.error("get_top_access_key: {}".format(e))
|
||||
raise e
|
||||
|
||||
|
||||
# The first parameter was url, and is now unused
|
||||
def request_bytedance_gateway(
|
||||
url: str,
|
||||
body: Any,
|
||||
additional_headers: Optional[Dict[str, str]],
|
||||
top_config: Dict[str, str],
|
||||
) -> requests.Response:
|
||||
top_ak = top_config["ak"]
|
||||
top_sk = top_config["sk"]
|
||||
|
||||
if not additional_headers:
|
||||
additional_headers = dict()
|
||||
|
||||
if top_config.get("aicc_saas_trn"):
|
||||
ak, sk, session_token = get_top_access_key(top_config)
|
||||
if ak and sk and session_token:
|
||||
top_ak = ak
|
||||
top_sk = sk
|
||||
additional_headers["X-Security-Token"] = session_token
|
||||
additional_headers["sts_token"] = session_token
|
||||
if top_config.get("target_uid"):
|
||||
additional_headers["UID"] = top_config.get("target_uid")
|
||||
|
||||
top_service = top_config["service"]
|
||||
top_region = top_config.get("region", "cn-beijing")
|
||||
top_method = top_config.get("method", "POST").upper()
|
||||
top_action = top_config.get("action", "GetAttestationBackend")
|
||||
top_version = top_config.get("version", "2024-12-24")
|
||||
url_rewrite = top_config.get("url_rewrite", "")
|
||||
http_scheme = top_config.get("http_scheme", "https")
|
||||
|
||||
top_url = top_config.get("url", "") if top_config.get("url", "") else url
|
||||
if not top_url:
|
||||
top_url = url_rewrite
|
||||
if not top_url:
|
||||
logger.error("ra request error url=None")
|
||||
raise error.ParamError("top_url", "top_url is None")
|
||||
|
||||
if top_url.startswith("http"):
|
||||
url_parsed = urllib.parse.urlparse(top_url)
|
||||
top_host = url_parsed.hostname
|
||||
else:
|
||||
top_host = top_url
|
||||
|
||||
params = {"Action": top_action, "Version": top_version}
|
||||
query_str = _format_query(params)
|
||||
|
||||
date_now = datetime.now(timezone.utc)
|
||||
body_str = json.dumps(body, separators=(",", ":"))
|
||||
|
||||
x_date = date_now.strftime("%Y%m%dT%H%M%SZ")
|
||||
short_x_date = x_date[:8]
|
||||
x_content_sha256 = _hash_sha256(body_str)
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Host": top_host,
|
||||
"X-Content-Sha256": x_content_sha256,
|
||||
"X-Date": x_date,
|
||||
}
|
||||
|
||||
signed_headers = ";".join(key.lower() for key in headers.keys())
|
||||
|
||||
canonical_request = "\n".join(
|
||||
[
|
||||
top_method,
|
||||
"/",
|
||||
query_str,
|
||||
*[key.lower() + ":" + value for key, value in headers.items()],
|
||||
"",
|
||||
signed_headers,
|
||||
x_content_sha256,
|
||||
]
|
||||
)
|
||||
|
||||
canonical_request_hash = _hash_sha256(canonical_request)
|
||||
credential_scope = "/".join([short_x_date, top_region, top_service, "request"])
|
||||
string_to_sign = "\n".join(
|
||||
["HMAC-SHA256", x_date, credential_scope, canonical_request_hash]
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"ra request={canonical_request}, hash={canonical_request_hash}, sign={string_to_sign}"
|
||||
)
|
||||
|
||||
k_date = _hmac_sha256(top_sk.encode(), short_x_date)
|
||||
k_region = _hmac_sha256(k_date, top_region)
|
||||
k_service = _hmac_sha256(k_region, top_service)
|
||||
k_signing = _hmac_sha256(k_service, "request")
|
||||
signature = _hmac_sha256(k_signing, string_to_sign).hex()
|
||||
|
||||
headers["Authorization"] = (
|
||||
f"HMAC-SHA256 Credential={top_ak}/{credential_scope}, SignedHeaders={signed_headers}, Signature={signature}"
|
||||
)
|
||||
|
||||
if url_rewrite:
|
||||
url_parsed = urllib.parse.urlparse(url_rewrite)
|
||||
headers["Host"] = url_parsed.hostname or ""
|
||||
url_final = url_rewrite + "?" + query_str
|
||||
else:
|
||||
if top_url.startswith("http"):
|
||||
url_final = top_url + "?" + query_str
|
||||
else:
|
||||
url_final = f"{http_scheme}://{top_url}/?{query_str}"
|
||||
|
||||
if additional_headers:
|
||||
headers = {**headers, **additional_headers}
|
||||
|
||||
r = requests.request(
|
||||
method=top_method, url=url_final, headers=headers, data=body_str, timeout=30
|
||||
)
|
||||
logger.debug(f"call ra: url={url_final}, header={headers}, body={body_str}, res={json.loads(r.text).get('Result')}")
|
||||
logger.info(f"ra response headers: X-Tt-Logid={r.headers.get('X-Tt-Logid')}")
|
||||
return r
|
||||
@@ -0,0 +1,41 @@
|
||||
__all__ = [
|
||||
"Attester",
|
||||
"EPSAttester",
|
||||
]
|
||||
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
import requests
|
||||
|
||||
from . import exceptions as exp
|
||||
|
||||
|
||||
class Attester(ABC):
|
||||
@abstractmethod
|
||||
def get_evidence(self, report_data: str) -> bytes:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class EPSAttester(Attester):
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
def get_evidence(self, report_data: str, pod_name: str = "", attest_gpu: bool = True) -> bytes:
|
||||
host_ip = os.environ.get("JPCC_HOST_IP", "localhost")
|
||||
eps_addr = f"{host_ip}:8006"
|
||||
if not pod_name:
|
||||
pod_name = os.environ.get("JPCC_POD_NAME")
|
||||
if not pod_name:
|
||||
pod_name = os.environ.get("HOSTNAME", "")
|
||||
|
||||
url = "http://{}/aa/evidence?runtime_data={}&pod_name={}&attest_gpu={}&static_report=true".format(
|
||||
eps_addr, report_data, pod_name, str(attest_gpu).lower())
|
||||
|
||||
try:
|
||||
resp = requests.get(url=url)
|
||||
resp.raise_for_status()
|
||||
except requests.RequestException as e:
|
||||
raise exp.RAError("Bad connection to EPS '{}': {}".format(url, e)) from e
|
||||
|
||||
return resp.content
|
||||
@@ -0,0 +1,628 @@
|
||||
import base64
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import os
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import requests
|
||||
from typing_extensions import IO, TYPE_CHECKING, Any, Dict, Optional, Union
|
||||
from volcenginesdkcore.rest import ApiException
|
||||
from bytedance.jeddak_secure_channel.ra import verify_ra_result, gen_report_data
|
||||
|
||||
from . import exceptions as exp
|
||||
from .attester import EPSAttester
|
||||
from .crypto import aes, ec
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from _typeshed import GenericPath
|
||||
else:
|
||||
GenericPath = str
|
||||
|
||||
__all__ = [
|
||||
"TKSClient",
|
||||
"TKSConfig",
|
||||
]
|
||||
|
||||
PATH_ATTEST = "/api/tks/v1/security/attest"
|
||||
PATH_RING_CREATE = "/api/tks/v1/ring/create"
|
||||
PATH_KEY_EXPORT = "/api/tks/v1/key/export"
|
||||
PATH_KEY_CREATE = "/api/tks/v1/key/create"
|
||||
PATH_KEY_IMPORT = "/api/tks/v1/key/import"
|
||||
PATH_POLICY_SET = "/api/tks/v1/policy/set"
|
||||
PATH_KEY_LIST = "/api/tks/v1/key/list"
|
||||
PATH_RING_LIST = "/api/tks/v1/ring/list"
|
||||
PATH_KEY_INFO = "/api/tks/v1/key/getinfo"
|
||||
PATH_RAS_VERIFY = "/api/ras/v1/verify"
|
||||
|
||||
|
||||
def utc8_timestamp() -> int:
|
||||
return int(datetime.now(timezone(timedelta(hours=8))).timestamp())
|
||||
|
||||
|
||||
@dataclass
|
||||
class TKSConfig:
|
||||
addr: str = "jeddakchain.bytedance.com"
|
||||
pod_name: str = ""
|
||||
enable_tls: bool = False
|
||||
pcc_config: dict = dataclasses.field(default_factory=dict)
|
||||
top_config: dict = dataclasses.field(default_factory=dict)
|
||||
|
||||
|
||||
class TKSClient:
|
||||
def __init__(self, app_id: str, config: TKSConfig) -> None:
|
||||
self._app_id = app_id
|
||||
self._config = config
|
||||
self.attester = EPSAttester()
|
||||
self.CHUNK_SIZE = 1024
|
||||
|
||||
def _request_tks(
|
||||
self,
|
||||
url: str,
|
||||
body: Any,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
cookies: Optional[Dict[str, str]] = None,
|
||||
) -> dict:
|
||||
try:
|
||||
if self._config.top_config:
|
||||
if self._config.top_config.get("url_rewrite", ""):
|
||||
from bytedance.jeddak_secure_channel.utils import (
|
||||
request_bytedance_gateway,
|
||||
)
|
||||
|
||||
resp = request_bytedance_gateway(
|
||||
url, body, headers, self._config.top_config
|
||||
)
|
||||
else:
|
||||
from bytedance.jeddak_secure_channel.utils import top_request
|
||||
|
||||
resp = top_request(url, body, headers, self._config.top_config)
|
||||
else:
|
||||
resp = requests.post(
|
||||
url=url, json=body, headers=headers, cookies=cookies
|
||||
)
|
||||
except ApiException as e:
|
||||
raise exp.TKSError(
|
||||
"Status '{}' from TKS, URL: '{}', Content: {}".format(
|
||||
e.status, url, e.body
|
||||
),
|
||||
http_status=e.status
|
||||
) from e
|
||||
except Exception as e:
|
||||
raise exp.TKSError("Network error") from e
|
||||
try:
|
||||
if isinstance(resp, requests.Response):
|
||||
resp.raise_for_status()
|
||||
resp = resp.json()
|
||||
except requests.HTTPError as e:
|
||||
raise exp.TKSError(
|
||||
"Status '{}' from TKS, URL: '{}', Content: {}".format(
|
||||
resp.status_code, url, resp.text
|
||||
),
|
||||
http_status=resp.status_code
|
||||
) from e
|
||||
except requests.RequestException as e:
|
||||
raise exp.TKSError("Bad connection to TKS: {}".format(e)) from e
|
||||
|
||||
# if "Result" not in resp:
|
||||
# raise exp.TKSError("Unexpected response: {}".format(resp))
|
||||
res = resp["Result"] if "Result" in resp else resp
|
||||
|
||||
return res
|
||||
|
||||
def challenge_server(self, nonce: str) -> str:
|
||||
return self._do_server_ra(nonce)["Report"]
|
||||
|
||||
def create_ring(self, ring_name: str, desc: str = "", **kwargs) -> dict:
|
||||
try:
|
||||
if self._config.top_config:
|
||||
url = self._config.addr
|
||||
self._config.top_config["action"] = "CreateTksRing"
|
||||
else:
|
||||
url = self._get_tks_url(PATH_RING_CREATE)
|
||||
|
||||
result = self._request_tks(
|
||||
url=url,
|
||||
body={"RingName": ring_name, "Description": desc},
|
||||
headers=self._get_headers(),
|
||||
**kwargs,
|
||||
)
|
||||
return result
|
||||
except requests.RequestException as e:
|
||||
raise exp.TKSError("create_ring to TKS: {}".format(e)) from e
|
||||
|
||||
def create_key(
|
||||
self,
|
||||
ring_id: str,
|
||||
algo: str,
|
||||
key_name: str = "",
|
||||
desc: str = "",
|
||||
**kwargs,
|
||||
) -> dict:
|
||||
key_meta = self._do_key_creation(
|
||||
ring_id, algo=algo, key_name=key_name, src="internal", desc=desc, **kwargs
|
||||
)
|
||||
return key_meta
|
||||
|
||||
def import_key(
|
||||
self,
|
||||
ring_id: str,
|
||||
algo: str,
|
||||
key: bytes,
|
||||
key_name: str = "",
|
||||
desc: str = "",
|
||||
usage_scenario="ModelEncryption",
|
||||
**kwargs,
|
||||
) -> dict:
|
||||
# Create an empty key record
|
||||
key_meta = self._do_key_creation(
|
||||
ring_id,
|
||||
algo=algo,
|
||||
key_name=key_name,
|
||||
src="external",
|
||||
desc=desc,
|
||||
usage_scenario=usage_scenario,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# Locally generate key pair
|
||||
sk_local, pk_local = ec.generate_key_pair()
|
||||
|
||||
# Challenge server
|
||||
ra_result = self._do_server_ra(bi_auth=False)
|
||||
pk_server = base64.b64decode(ra_result["DHParam"].encode())
|
||||
|
||||
# Encrypt key
|
||||
ek = ec.diffie_hellman_key_exchange(sk_local, pk_server)
|
||||
encrypted_key = aes.gcm_encrypt(ek, key)
|
||||
|
||||
if self._config.top_config:
|
||||
url = self._config.addr
|
||||
self._config.top_config["action"] = "ImportTksKey"
|
||||
else:
|
||||
url = self._get_tks_url(PATH_KEY_IMPORT)
|
||||
|
||||
_ = self._request_tks(
|
||||
url=url,
|
||||
body={
|
||||
"RingID": ring_id,
|
||||
"KeyID": key_meta["KeyID"],
|
||||
"Key": base64.b64encode(encrypted_key).decode(),
|
||||
"DHParam": base64.b64encode(pk_local).decode(),
|
||||
},
|
||||
headers=self._get_headers(),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
return key_meta
|
||||
|
||||
def get_key_info(
|
||||
self,
|
||||
ring_id: str,
|
||||
key_id: str,
|
||||
) -> dict:
|
||||
if self._config.top_config:
|
||||
url = self._config.addr
|
||||
self._config.top_config["action"] = "GetTksKeyinfo"
|
||||
else:
|
||||
url = self._get_tks_url(PATH_KEY_INFO)
|
||||
result = self._request_tks(
|
||||
url=url,
|
||||
body={
|
||||
"RingID": ring_id,
|
||||
"KeyID": key_id,
|
||||
},
|
||||
headers=self._get_headers(),
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def get_key(
|
||||
self,
|
||||
ring_id: str,
|
||||
key_id: str,
|
||||
need_evidence: bool = True,
|
||||
attest_gpu: bool = True,
|
||||
policy_id='',
|
||||
tks_url='',
|
||||
tks_uid='',
|
||||
tks_service_name='',
|
||||
**kwargs,
|
||||
) -> bytes:
|
||||
print(f'tks get_key: policy_id={policy_id}, url={tks_url}, uid={tks_uid}, ra_service_name={tks_service_name}')
|
||||
nonce = secrets.token_hex(32)
|
||||
# 获取TKS的证明报告
|
||||
ra_result = self._do_server_ra(nonce=nonce, bi_auth=True)
|
||||
# 调用verify接口对TKS进行远程证明
|
||||
if policy_id:
|
||||
if self._config.top_config:
|
||||
url = self._config.addr
|
||||
self._config.top_config["action"] = "VerifyRasEvidence"
|
||||
else:
|
||||
url = self._get_tks_url(PATH_RAS_VERIFY)
|
||||
report_data_bytes = gen_report_data(nonce, ra_result["Challenge"]["NonceDown"], ra_result["DHParam"],
|
||||
ra_result['Challenge']['ExpirationTime'])
|
||||
|
||||
urlsafe_base64 = base64.urlsafe_b64encode(report_data_bytes)
|
||||
report_data = urlsafe_base64.rstrip(b'=').decode('utf-8')
|
||||
req_body = {
|
||||
"Evidence": ra_result.get("Report").rstrip('='),
|
||||
"PolicyId": policy_id,
|
||||
"ReportData": {"Raw": report_data}
|
||||
}
|
||||
res = self._request_tks(
|
||||
url=url, body=req_body, headers=self._get_headers(), **kwargs
|
||||
)
|
||||
verify_res = verify_ra_result(res.get("AttestationResult"), None, "", None, nonce,
|
||||
report_data_bytes.hex())
|
||||
if not verify_res:
|
||||
raise Exception("verify tks failed!!!")
|
||||
print("verify tks success!!!")
|
||||
|
||||
# get pub_key from server
|
||||
pk_server = base64.b64decode(ra_result["DHParam"].encode())
|
||||
|
||||
sk_local, pk_local = ec.generate_key_pair()
|
||||
req_body = {
|
||||
"AppID": self._app_id,
|
||||
"RingID": ring_id,
|
||||
"KeyID": key_id,
|
||||
"DHParam": base64.b64encode(pk_local).decode(),
|
||||
"ClientChall": ra_result["Challenge"],
|
||||
# "RAEvidence": {"TEEType": "coco", "Report": base64.b64encode(evidence).decode()},
|
||||
}
|
||||
|
||||
if need_evidence:
|
||||
evidence = self.attester.get_evidence(
|
||||
ra_result["Challenge"]["NonceDown"][:64],
|
||||
self._config.pod_name,
|
||||
attest_gpu,
|
||||
)
|
||||
|
||||
req_body["RAEvidence"] = {
|
||||
"TEEType": "coco",
|
||||
"Report": base64.b64encode(evidence).decode(),
|
||||
}
|
||||
if self._config.top_config:
|
||||
url = self._config.addr
|
||||
self._config.top_config["action"] = "ExportTksKey"
|
||||
else:
|
||||
url = self._get_tks_url(PATH_KEY_EXPORT)
|
||||
|
||||
res = self._request_tks(
|
||||
url=url, body=req_body, headers=self._get_headers(), **kwargs
|
||||
)
|
||||
|
||||
encrypted_key = res["Key"]
|
||||
|
||||
ek = ec.diffie_hellman_key_exchange(local_sk=sk_local, peer_pk=pk_server)
|
||||
|
||||
data_key = aes.gcm_decrypt(ek, base64.b64decode(encrypted_key))
|
||||
|
||||
return data_key
|
||||
|
||||
def set_key_policy(self, ring_id: str, key_id: str, policy: str, **kwargs) -> dict:
|
||||
if self._config.top_config:
|
||||
url = self._config.addr
|
||||
self._config.top_config["action"] = "ImportTksKey"
|
||||
else:
|
||||
url = self._get_tks_url(PATH_POLICY_SET)
|
||||
|
||||
result = self._request_tks(
|
||||
url=url,
|
||||
headers=self._get_headers(),
|
||||
body={
|
||||
"ID": key_id,
|
||||
"Range": "key",
|
||||
"Rules": policy,
|
||||
},
|
||||
**kwargs,
|
||||
)
|
||||
return result
|
||||
|
||||
def _do_key_creation(
|
||||
self,
|
||||
ring_id: str,
|
||||
algo: str,
|
||||
key_name: str = "",
|
||||
src: str = "internal",
|
||||
desc: str = "",
|
||||
usage_scenario: str = "ModelEncryption",
|
||||
**kwargs,
|
||||
) -> dict:
|
||||
if self._config.top_config:
|
||||
url = self._config.addr
|
||||
self._config.top_config["action"] = "CreateTksKey"
|
||||
else:
|
||||
url = self._get_tks_url(PATH_KEY_CREATE)
|
||||
result = self._request_tks(
|
||||
url=url,
|
||||
body={
|
||||
"RingID": ring_id,
|
||||
"KeyName": key_name,
|
||||
"Algo": algo,
|
||||
"Source": src,
|
||||
"Description": desc,
|
||||
"UsageScenario": usage_scenario,
|
||||
},
|
||||
headers=self._get_headers(),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def list_ring_id(
|
||||
self,
|
||||
page_number: int = 0,
|
||||
page_size: int = 10,
|
||||
show_invisible: bool = False,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
:param page_size:
|
||||
:param page_number:
|
||||
:param show_invisible:
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
if self._config.top_config:
|
||||
url = self._config.addr
|
||||
self._config.top_config["action"] = "ListTksRing"
|
||||
else:
|
||||
url = self._get_tks_url(PATH_RING_LIST)
|
||||
|
||||
body = {
|
||||
"PageSize": page_size,
|
||||
"PageNumber": page_number,
|
||||
"ShowInvisible": show_invisible,
|
||||
}
|
||||
|
||||
result = self._request_tks(
|
||||
url=url, body=body, headers=self._get_headers(), **kwargs
|
||||
)
|
||||
return result
|
||||
except requests.RequestException as e:
|
||||
raise exp.TKSError("list ring id error: {}".format(e)) from e
|
||||
|
||||
def list_key(
|
||||
self,
|
||||
ring_id: Optional[str] = None,
|
||||
page_number: int = 0,
|
||||
page_size: int = 10,
|
||||
status: Optional[str] = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
:param ring_id:
|
||||
:param page_size:
|
||||
:param page_number:
|
||||
:param status:
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
if self._config.top_config:
|
||||
url = self._config.addr
|
||||
self._config.top_config["action"] = "ListTksKey"
|
||||
else:
|
||||
url = self._get_tks_url(PATH_KEY_LIST)
|
||||
|
||||
body: dict = {"PageSize": page_size, "PageNumber": page_number}
|
||||
if ring_id:
|
||||
body["RingID"] = ring_id
|
||||
if status:
|
||||
body["Status"] = status
|
||||
|
||||
result = self._request_tks(
|
||||
url=url, body=body, headers=self._get_headers(), **kwargs
|
||||
)
|
||||
return result
|
||||
except requests.RequestException as e:
|
||||
raise exp.TKSError("list key error: {}".format(e)) from e
|
||||
|
||||
def _do_server_ra(
|
||||
self, nonce: Optional[str] = None, bi_auth: bool = True, **kwargs
|
||||
) -> dict:
|
||||
if nonce is None:
|
||||
nonce = secrets.token_hex(32)
|
||||
if self._config.top_config:
|
||||
url = self._config.addr
|
||||
self._config.top_config["action"] = "AttestTksSecurity"
|
||||
else:
|
||||
url = self._get_tks_url(PATH_ATTEST)
|
||||
|
||||
resp = self._request_tks(
|
||||
url=url,
|
||||
body={"NonceUp": nonce, "BiAuth": bi_auth},
|
||||
headers=self._get_headers(),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
return resp
|
||||
|
||||
def _get_tks_url(self, path: str) -> str:
|
||||
if self._config.addr.startswith("http"):
|
||||
return "{}{}".format(self._config.addr, path)
|
||||
else:
|
||||
scheme = "https" if self._config.enable_tls else "http"
|
||||
return "{}://{}{}".format(scheme, self._config.addr, path)
|
||||
|
||||
def _generate_pcc_token(self, timestamp: str) -> str:
|
||||
"""
|
||||
生成访问bytedance pcc_controller的token
|
||||
:param timestamp:
|
||||
:return:
|
||||
"""
|
||||
ak = self._config.pcc_config["ak"]
|
||||
sk = self._config.pcc_config["sk"]
|
||||
|
||||
h = hashlib.sha256()
|
||||
h.update(sk.encode())
|
||||
sk = h.hexdigest()
|
||||
h = hashlib.sha256()
|
||||
h.update((ak + sk + str(timestamp)).encode())
|
||||
token = h.hexdigest()
|
||||
return token
|
||||
|
||||
def _get_headers(self) -> dict:
|
||||
timestamp = str(int(datetime.now(timezone.utc).timestamp()))
|
||||
headers = {
|
||||
"AppID": self._app_id,
|
||||
# "Timestamp": str(utc8_timestamp()),
|
||||
"Timestamp": timestamp,
|
||||
# "nonce": "a314b43502077cd89d21378b29954207aaf5b3848d4d7fa9f16682ba1f6f3317"
|
||||
}
|
||||
|
||||
if self._config.pcc_config:
|
||||
headers["Content-Type"] = "application/json"
|
||||
headers["J-Internal-Ak"] = self._config.pcc_config["ak"]
|
||||
headers["J-Internal-Token"] = self._generate_pcc_token(timestamp)
|
||||
|
||||
return headers
|
||||
|
||||
def aes_encrypt(self, aes_key: Union[bytes, str], data: Union[bytes, str]) -> bytes:
|
||||
if isinstance(aes_key, str):
|
||||
aes_key = aes_key.encode()
|
||||
if isinstance(data, str):
|
||||
data = data.encode()
|
||||
return aes.gcm_encrypt(aes_key, data)
|
||||
|
||||
def aes_decrypt(self, aes_key: Union[bytes, str], data: Union[bytes, str]) -> bytes:
|
||||
if isinstance(aes_key, str):
|
||||
aes_key = aes_key.encode()
|
||||
if isinstance(data, str):
|
||||
data = data.encode()
|
||||
return aes.gcm_decrypt(aes_key, data)
|
||||
|
||||
def encrypt_file(
|
||||
self,
|
||||
aes_key: Union[bytes, str],
|
||||
source_path: str,
|
||||
dest_path: str,
|
||||
mode: str = "b",
|
||||
) -> bool:
|
||||
if mode not in ["b", "t"]:
|
||||
raise Exception("mode argument error")
|
||||
|
||||
if isinstance(aes_key, str):
|
||||
aes_key = aes_key.encode()
|
||||
|
||||
with open(source_path, f"r{mode}") as source, open(
|
||||
dest_path, f"w{mode}"
|
||||
) as dest:
|
||||
if mode == "b":
|
||||
source_b: IO[bytes] = source
|
||||
dest_b: IO[bytes] = dest
|
||||
plaintext = source_b.read()
|
||||
ciphertext = self.aes_encrypt(aes_key, plaintext)
|
||||
dest_b.write(ciphertext)
|
||||
else:
|
||||
source_t: IO[str] = source
|
||||
dest_t: IO[str] = dest
|
||||
for plaintext in source_t:
|
||||
plaintext = plaintext.rstrip("\n")
|
||||
ciphertext = self.aes_encrypt(aes_key, plaintext)
|
||||
dest_t.write(base64.b64encode(ciphertext).decode() + "\n")
|
||||
|
||||
return True
|
||||
|
||||
def decrypt_file(
|
||||
self,
|
||||
aes_key: Union[bytes, str],
|
||||
source_path: str,
|
||||
dest_path: str,
|
||||
mode: str = "b",
|
||||
) -> bool:
|
||||
if mode not in ["b", "t"]:
|
||||
raise Exception("mode argument error")
|
||||
|
||||
if isinstance(aes_key, str):
|
||||
aes_key = aes_key.encode()
|
||||
|
||||
with open(source_path, f"r{mode}") as source, open(
|
||||
dest_path, f"w{mode}"
|
||||
) as dest:
|
||||
if mode == "b":
|
||||
source_b: IO[bytes] = source
|
||||
dest_b: IO[bytes] = dest
|
||||
ciphertext = source_b.read()
|
||||
plaintext = self.aes_decrypt(aes_key, ciphertext)
|
||||
dest_b.write(plaintext)
|
||||
else:
|
||||
source_t: IO[str] = source
|
||||
dest_t: IO[str] = dest
|
||||
for ciphertext in source_t:
|
||||
ciphertext = ciphertext.rstrip("\n")
|
||||
ciphertext = base64.b64decode(ciphertext.encode())
|
||||
plaintext = self.aes_decrypt(aes_key, ciphertext)
|
||||
dest_t.write(plaintext.decode() + "\n")
|
||||
# dest_t.write(base64.b64encode(plaintext).decode() + "\n")
|
||||
return True
|
||||
|
||||
|
||||
default_config = TKSConfig()
|
||||
|
||||
if __name__ == "__main__":
|
||||
def md5_hash(data: bytes, salt: str = "") -> str:
|
||||
return hashlib.md5(data + salt.encode()).hexdigest()
|
||||
|
||||
|
||||
ts = str(utc8_timestamp())
|
||||
req_body = {"Username": "alice", "Password": md5_hash("alice0".encode(), ts)}
|
||||
response = requests.post(
|
||||
"https://jeddakchain.bytedance.com/api/user/login",
|
||||
headers={"TimeStamp": ts},
|
||||
json=req_body,
|
||||
)
|
||||
cookies = response.cookies
|
||||
jeddak_pcc_session = cookies.get("jeddak_pcc_session")
|
||||
|
||||
cookies = {"jeddak_pcc_session": jeddak_pcc_session}
|
||||
|
||||
cfg = TKSConfig(addr="jeddakchain.bytedance.com", enable_tls=True)
|
||||
client = TKSClient("alice", cfg)
|
||||
# print(
|
||||
# "Key Get",
|
||||
# client.get_key(
|
||||
# "2ee6348f-fafa-4e97-a824-0b6ed0e4d07e", "f532b84c-8f80-4705-b5a7-6217f4729924"
|
||||
# ),
|
||||
# )
|
||||
#
|
||||
# print("Challenge Server", client.challenge_server("1234"))
|
||||
# print(
|
||||
# "Key Creation",
|
||||
# client.create_key(
|
||||
# "4894a298-759a-4b94-a292-f43b2f8deea6",
|
||||
# "SYMMETRIC_256",
|
||||
# "test-encrypt-sdk",
|
||||
# desc="测试本地加密SDK",
|
||||
# cookies=cookies,
|
||||
# ),
|
||||
# )
|
||||
# )
|
||||
print(
|
||||
"Key Insert",
|
||||
client.import_key(
|
||||
"4894a298-759a-4b94-a292-f43b2f8deea6",
|
||||
"SYMMETRIC_256",
|
||||
os.urandom(32),
|
||||
"test-py-import",
|
||||
cookies=cookies,
|
||||
),
|
||||
)
|
||||
# print("Key Set Policy", client.set_key_policy(
|
||||
# "b3bf3cd7-2a68-427f-865f-8688dc96c7d8",
|
||||
# "1110fb2f-8dca-4e27-9a37-04a60585fd29",
|
||||
# json.dumps([
|
||||
# [
|
||||
# {
|
||||
# "version": "v1",
|
||||
# "type": "RAPolicy",
|
||||
# "optional": False,
|
||||
# "inversion": False,
|
||||
# "policyID": "78485ba8-4e67-46a5-a34e-1a9b7930df02"
|
||||
# }
|
||||
# ]
|
||||
# ])
|
||||
# ))
|
||||
@@ -0,0 +1,122 @@
|
||||
import os
|
||||
|
||||
|
||||
class Conf:
|
||||
# RELATIVE_CONST_INDEX_PATH = './templates/html/main/index.html'
|
||||
# CONST_INDEX_PATH = './html/main/index.html'
|
||||
|
||||
# cookie key名称,用以保存登陆态的session token
|
||||
SESSION_COOKIE_KEY = "session_token"
|
||||
# 合法时间戳的最大误差,单位秒
|
||||
TIMESTAMP_THRESHOLD = 60
|
||||
# session token的过期时间,单位秒
|
||||
SESSION_TOKEN_EXPIRE = 86400
|
||||
# redis配置
|
||||
REDIS_HOST = "localhost"
|
||||
REDIS_PORT = 6379
|
||||
REDIS_USERNAME = "default"
|
||||
REDIS_PASSWORD = ""
|
||||
# pcc后端的分流配置
|
||||
BACKEND_MAP = {
|
||||
"/api/tks/": ["http://localhost:10000", "appID", "AK", "SK"],
|
||||
"/api/bgs/": ["http://localhost:10001", "UID", "AK", "SK"],
|
||||
"/api/ras/": ["http://localhost:10002", "UID", "AK", "SK"],
|
||||
"/api/rag/": ["http://localhost:10003", "UID", "AK", "SK"],
|
||||
}
|
||||
STREAM_PATH = "/api/rag/collection/query_and_generate"
|
||||
|
||||
UPLOAD_FOLDER = os.path.abspath("./upload/rag/doc")
|
||||
DOWNLOAD_FOLDER = os.path.abspath("./download/attest/report")
|
||||
ALLOWED_EXTENSIONS = {".txt", ".pdf", ".pptx", ".docx"}
|
||||
|
||||
# pcc后端转发配置
|
||||
PCC_UID = "jeddak_team"
|
||||
PCC_PASSWORD = "some_password"
|
||||
PCC_AK = "some_AK"
|
||||
PCC_SK = "some_SK"
|
||||
RING_ID = "id_1234"
|
||||
POLICY_ID = "policy_1234"
|
||||
KEY_TYPE = "SYMMETRIC_256"
|
||||
|
||||
RAG_SERVICE_NAME = "rag-deployment"
|
||||
HANDLER_SERVICE_NAME = "ai-handler"
|
||||
# RAG_SERVICE = {
|
||||
# "ServiceInfo": {
|
||||
# "_key": "rag",
|
||||
# "_val": "rag-deployment"
|
||||
# }
|
||||
# }
|
||||
# HANDLER_SERVICE = {
|
||||
# "ServiceInfo": {
|
||||
# "_key": "handler",
|
||||
# "_val": "ai-handler"
|
||||
# }
|
||||
# }
|
||||
# SERVICE_INFO_LIST = {"rag": RAG_SERVICE, "handler": HANDLER_SERVICE}
|
||||
SERVICE_INFO = {"ServiceInfo": {"rag": RAG_SERVICE_NAME, "handler": HANDLER_SERVICE_NAME}}
|
||||
|
||||
PCC_URL = "https://jeddakchain.bytedance.com"
|
||||
PATH_MAP = {
|
||||
"/api_mobile/doc/update": "/api/rag/collection",
|
||||
"/api_mobile/rag/query_and_generate": "/api/rag/collection/query_and_generate",
|
||||
# security使用同一个接口,如果改成不同,ENV处理逻辑要改
|
||||
"/api_mobile/security/status": "/api/ras/v1/attestation_user",
|
||||
"/api_mobile/security/status/download": "/api/ras/v1/attestation_user",
|
||||
# TODO: 确定接口
|
||||
"/api_mobile/key/set": "/api/tks/",
|
||||
}
|
||||
|
||||
# RAG_ADDR = 'http://localhost:10003'
|
||||
# RAG_COLLECTION_CREATE_ENDPOINT = '/api/rag/collection/create'
|
||||
# RAS_SHIM_ADDR = "http://localhost:10002"
|
||||
|
||||
DOC_STATUS_MAP = {"creating": "处理中", "ready": "处理完成", "failed": "处理失败"}
|
||||
|
||||
SALT = "ai-handler-salt"
|
||||
|
||||
def update_from_env(self):
|
||||
for k in self.BACKEND_MAP:
|
||||
env_key = f"ENV_{k.split('/')[-2].upper()}_URL"
|
||||
# ENV_TKS_URL, ...
|
||||
if env_key in os.environ:
|
||||
self.BACKEND_MAP[k][0] = os.getenv(env_key)
|
||||
for k in self.PATH_MAP:
|
||||
env_key = f"HANDLER_{k.split('/')[2].upper()}_URL"
|
||||
if env_key in os.environ:
|
||||
self.PATH_MAP[k] = os.getenv(env_key)
|
||||
self.REDIS_USERNAME = (
|
||||
os.getenv("ENV_REDIS_USERNAME") if "ENV_REDIS_USERNAME" in os.environ else None
|
||||
)
|
||||
self.REDIS_PASSWORD = (
|
||||
os.getenv("ENV_REDIS_PASSWORD") if "ENV_REDIS_PASSWORD" in os.environ else None
|
||||
)
|
||||
if "ENV_REDIS_HOST" in os.environ:
|
||||
self.REDIS_HOST = os.getenv("ENV_REDIS_HOST")
|
||||
if "ENV_REDIS_PORT" in os.environ:
|
||||
self.REDIS_PORT = int(os.getenv("ENV_REDIS_PORT"))
|
||||
if "ENV_SESSION_TOKEN_EXPIRE" in os.environ:
|
||||
self.SESSION_TOKEN_EXPIRE = int(os.getenv("ENV_SESSION_TOKEN_EXPIRE"))
|
||||
if "RAG_SERVICE_NAME" in os.environ:
|
||||
self.RAG_SERVICE_NAME = os.getenv("RAG_SERVICE_NAME")
|
||||
if "HANDLER_SERVICE_NAME" in os.environ:
|
||||
self.HANDLER_SERVICE_NAME = os.getenv("HANDLER_SERVICE_NAME")
|
||||
if "RING_ID" in os.environ:
|
||||
self.RING_ID = os.getenv("RING_ID")
|
||||
if "POLICY_ID" in os.environ:
|
||||
self.POLICY_ID = os.getenv("POLICY_ID")
|
||||
if "KEY_TYPE" in os.environ:
|
||||
self.KEY_TYPE = os.getenv("KEY_TYPE")
|
||||
if "PCC_UID" in os.environ:
|
||||
self.PCC_UID = os.getenv("PCC_UID")
|
||||
if "PCC_PASSWORD" in os.environ:
|
||||
self.PCC_PASSWORD = os.getenv("PCC_PASSWORD")
|
||||
if "PCC_AK" in os.environ:
|
||||
self.PCC_AK = os.getenv("PCC_AK")
|
||||
if "PCC_SK" in os.environ:
|
||||
self.PCC_SK = os.getenv("PCC_SK")
|
||||
os.makedirs(conf.UPLOAD_FOLDER, exist_ok=True)
|
||||
os.makedirs(conf.DOWNLOAD_FOLDER, exist_ok=True)
|
||||
print(f"final backend_map: {self.BACKEND_MAP}")
|
||||
|
||||
|
||||
conf = Conf()
|
||||
@@ -0,0 +1,47 @@
|
||||
__all__ = [
|
||||
"gcm_encrypt",
|
||||
"gcm_decrypt",
|
||||
]
|
||||
|
||||
import secrets
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
|
||||
from ..consts import conf
|
||||
|
||||
|
||||
def gcm_decrypt(key: bytes, ciphertext: bytes) -> bytes:
|
||||
iv, ciphertext = ciphertext[:12], ciphertext[12:]
|
||||
ciphertext, tag = ciphertext[:-16], ciphertext[-16:]
|
||||
decryptor = Cipher(
|
||||
algorithms.AES(key),
|
||||
modes.GCM(iv, tag),
|
||||
).decryptor()
|
||||
|
||||
return decryptor.update(ciphertext) + decryptor.finalize()
|
||||
|
||||
|
||||
def gcm_encrypt(key: bytes, plaintext: bytes) -> bytes:
|
||||
iv = secrets.token_bytes(12)
|
||||
encryptor = Cipher(
|
||||
algorithms.AES(key),
|
||||
modes.GCM(iv),
|
||||
).encryptor()
|
||||
|
||||
ciphertext = encryptor.update(plaintext) + encryptor.finalize()
|
||||
return iv + ciphertext + encryptor.tag
|
||||
|
||||
|
||||
def generate_key_from_string(seed_string: str) -> bytes:
|
||||
backend = default_backend()
|
||||
# 将字符串种子转换为字节
|
||||
password = seed_string.encode()
|
||||
salt = conf.SALT.encode()
|
||||
kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA256(), length=32, salt=salt, iterations=100000, backend=backend
|
||||
)
|
||||
key = kdf.derive(password)
|
||||
return key
|
||||
@@ -0,0 +1,39 @@
|
||||
from typing import Tuple
|
||||
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
|
||||
__all__ = [
|
||||
"diffie_hellman_key_exchange",
|
||||
"generate_key_pair",
|
||||
]
|
||||
|
||||
|
||||
def diffie_hellman_key_exchange(local_sk: bytes, peer_pk: bytes) -> bytes:
|
||||
local_sk_key = serialization.load_pem_private_key(local_sk, password=None)
|
||||
peer_pk_key = serialization.load_pem_public_key(peer_pk)
|
||||
assert isinstance(local_sk_key, ec.EllipticCurvePrivateKey)
|
||||
assert isinstance(peer_pk_key, ec.EllipticCurvePublicKey)
|
||||
shared_key = local_sk_key.exchange(ec.ECDH(), peer_pk_key)
|
||||
derived_key = HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=None).derive(
|
||||
shared_key
|
||||
)
|
||||
return derived_key
|
||||
|
||||
|
||||
def generate_key_pair() -> Tuple[bytes, bytes]:
|
||||
private_key = ec.generate_private_key(ec.SECP384R1())
|
||||
public_key = private_key.public_key()
|
||||
|
||||
private_key_pem = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
|
||||
public_key_pem = public_key.public_bytes(
|
||||
encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
)
|
||||
|
||||
return private_key_pem, public_key_pem
|
||||
@@ -0,0 +1,22 @@
|
||||
from typing import Optional
|
||||
|
||||
__all__ = [
|
||||
"TKSError",
|
||||
"RAError",
|
||||
]
|
||||
|
||||
|
||||
class TKSError(Exception):
|
||||
def __init__(self, message: str, http_status: Optional[int] = None):
|
||||
super().__init__(message)
|
||||
self.http_status = http_status
|
||||
|
||||
def __str__(self):
|
||||
if self.http_status:
|
||||
return f"{self.args[0]} (HTTP Status: {self.http_status})"
|
||||
return self.args[0]
|
||||
|
||||
|
||||
class RAError(TKSError):
|
||||
def __init__(self, message: str, http_status: Optional[int] = None):
|
||||
super().__init__(message, http_status)
|
||||
@@ -0,0 +1,14 @@
|
||||
__all__ = [
|
||||
"AICCClient",
|
||||
"AICCModelType",
|
||||
"__version__",
|
||||
]
|
||||
|
||||
__version__ = "0.0.31"
|
||||
|
||||
from .aicc_client import AICCModelType
|
||||
from .aicc_client import AICCClient
|
||||
|
||||
|
||||
def __getattr__(name: str) -> object:
|
||||
raise AttributeError(f"module {__name__} has no attribute {name}")
|
||||
@@ -0,0 +1,225 @@
|
||||
from bytedance.volcengine_aicc_sdk.seedance_inference_client import SeedanceClient
|
||||
from bytedance.volcengine_aicc_sdk.seedream_inference_client import SeedreamClient
|
||||
"""
|
||||
AICC访问客户端
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
"AICCClient",
|
||||
"AICCModelType"
|
||||
]
|
||||
|
||||
from bytedance.volcengine_aicc_sdk.cu_inference_client import CUClient
|
||||
from bytedance.volcengine_aicc_sdk.auc_inference_client import AUCClient
|
||||
from bytedance.volcengine_aicc_sdk.ark_inference_client import ARKClient
|
||||
from bytedance.volcengine_aicc_sdk.deepseek_inference_client import DeepSeekClient
|
||||
from bytedance.volcengine_aicc_sdk.asr_websocket_client import AsrWebSocketClient
|
||||
from .asr_inference_client import ASRClient
|
||||
from volcenginesdkarkruntime import Ark
|
||||
from .common.error import AICCException
|
||||
from .common.log import LogConfig, logger
|
||||
from bytedance.volcengine_aicc_sdk.secure_http_client import AICCConfig
|
||||
|
||||
|
||||
class AICCModelType:
|
||||
DOUBAO = "DOUBAO" # 机密豆包
|
||||
AICC_CU = "AICC-CU" # AICC CU版模型
|
||||
OSLM = "OSLM" # 开源模型
|
||||
ASR = "ASR"
|
||||
ASR_WS = "ASR-WS"
|
||||
SEEDANCE = "SEEDANCE"
|
||||
SEEDREAM = "SEEDREAM"
|
||||
AUC = "AUC"
|
||||
|
||||
|
||||
class AICCClient:
|
||||
"""
|
||||
AICC机密推理访问客户端.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
base_url: str = "",
|
||||
api_key: str = "",
|
||||
endpoint: str = "",
|
||||
aicc_config: AICCConfig = None,
|
||||
**kwargs
|
||||
):
|
||||
self.base_url = base_url
|
||||
self.api_key = api_key
|
||||
self.aicc_config = aicc_config
|
||||
self.endpoint = endpoint
|
||||
self.kwargs = kwargs
|
||||
|
||||
if not self.base_url or not self.api_key:
|
||||
raise AICCException(f"New AICC client failed, base_url={self.base_url}, api_key={self.api_key}")
|
||||
|
||||
if not self.endpoint:
|
||||
self.endpoint = self.kwargs.get("model_name")
|
||||
|
||||
self.config = dict()
|
||||
self._init_config()
|
||||
self._init_log()
|
||||
|
||||
def get_llm_instance(self, model_type):
|
||||
model_type = model_type.upper()
|
||||
if AICCModelType.SEEDANCE in model_type:
|
||||
return SeedanceClient(base_url=self.base_url,
|
||||
api_key=self.api_key,
|
||||
endpoint=self.endpoint,
|
||||
aicc_config=self.aicc_config,
|
||||
**self.kwargs)
|
||||
elif AICCModelType.AICC_CU in model_type:
|
||||
return CUClient(base_url=self.base_url,
|
||||
aicc_service_name=self.aicc_config.aicc_llm_service_name,
|
||||
policy_id=self.aicc_config.policy_id,
|
||||
is_openai=self.kwargs.get("is_openai"),
|
||||
is_secure=self.kwargs.get("is_secure"),
|
||||
ak=self.aicc_config.ak,
|
||||
sk=self.aicc_config.sk,
|
||||
region=self.aicc_config.region,
|
||||
**self.kwargs)
|
||||
elif AICCModelType.ASR == model_type:
|
||||
return ASRClient(base_url=self.base_url,
|
||||
aicc_service_name=self.aicc_config.aicc_llm_service_name,
|
||||
policy_id=self.aicc_config.policy_id,
|
||||
is_openai=self.kwargs.get("is_openai"),
|
||||
is_secure=self.kwargs.get("is_secure"),
|
||||
ak=self.aicc_config.ak,
|
||||
sk=self.aicc_config.sk,
|
||||
region=self.aicc_config.region,
|
||||
**self.kwargs)
|
||||
elif AICCModelType.OSLM in model_type:
|
||||
return DeepSeekClient(base_url=self.base_url,
|
||||
api_key=self.api_key,
|
||||
ep=self.endpoint,
|
||||
aicc_service_name=self.aicc_config.aicc_llm_service_name,
|
||||
policy_id=self.aicc_config.policy_id,
|
||||
is_openai=self.kwargs.get("is_openai"),
|
||||
is_secure=self.kwargs.get("is_secure"),
|
||||
ak=self.aicc_config.ak,
|
||||
sk=self.aicc_config.sk,
|
||||
region=self.aicc_config.region,
|
||||
**self.kwargs)
|
||||
elif AICCModelType.DOUBAO in model_type:
|
||||
return ARKClient(base_url=self.base_url,
|
||||
api_key=self.api_key,
|
||||
ep=self.endpoint,
|
||||
is_openai=self.kwargs.get("is_openai"),
|
||||
is_secure=self.kwargs.get("is_secure"),
|
||||
ak=self.aicc_config.ak,
|
||||
sk=self.aicc_config.sk,
|
||||
region=self.aicc_config.region,
|
||||
**self.kwargs)
|
||||
elif AICCModelType.ASR_WS == model_type:
|
||||
return AsrWebSocketClient(base_url=self.base_url,
|
||||
api_key=self.api_key,
|
||||
endpoint=self.endpoint,
|
||||
aicc_config=self.aicc_config,
|
||||
**self.kwargs)
|
||||
elif AICCModelType.AUC == model_type:
|
||||
return AUCClient(base_url=self.base_url,
|
||||
api_key=self.api_key,
|
||||
endpoint=self.endpoint,
|
||||
aicc_config=self.aicc_config,
|
||||
**self.kwargs)
|
||||
elif AICCModelType.SEEDREAM == model_type:
|
||||
return SeedreamClient(base_url=self.base_url,
|
||||
api_key=self.api_key,
|
||||
endpoint=self.endpoint,
|
||||
aicc_config=self.aicc_config,
|
||||
**self.kwargs)
|
||||
else:
|
||||
raise AICCException(f"New AICC client failed, model_type={model_type}")
|
||||
|
||||
@staticmethod
|
||||
def get_inference_instance(model_type: str,
|
||||
base_url: str = None,
|
||||
api_key: str = None,
|
||||
ep: str = None,
|
||||
aicc_service_name: str = None,
|
||||
policy_id: str = None,
|
||||
is_openai: bool = False,
|
||||
is_secure: bool = True,
|
||||
ak: str = None,
|
||||
sk: str = None,
|
||||
region: str = "cn-beijing",
|
||||
**kwargs):
|
||||
if model_type.upper() == AICCModelType.DOUBAO:
|
||||
return ARKClient(base_url=base_url,
|
||||
api_key=api_key,
|
||||
ep=ep,
|
||||
is_openai=is_openai,
|
||||
is_secure=is_secure,
|
||||
ak=ak,
|
||||
sk=sk,
|
||||
region=region,
|
||||
**kwargs)
|
||||
elif model_type.upper() == AICCModelType.AICC_CU:
|
||||
return CUClient(base_url=base_url,
|
||||
aicc_service_name=aicc_service_name,
|
||||
policy_id=policy_id,
|
||||
is_openai=is_openai,
|
||||
is_secure=is_secure,
|
||||
ak=ak,
|
||||
sk=sk,
|
||||
region=region,
|
||||
**kwargs)
|
||||
elif model_type.upper() == AICCModelType.OSLM:
|
||||
return DeepSeekClient(base_url=base_url,
|
||||
api_key=api_key,
|
||||
ep=ep,
|
||||
aicc_service_name=aicc_service_name,
|
||||
policy_id=policy_id,
|
||||
is_openai=is_openai,
|
||||
is_secure=is_secure,
|
||||
ak=ak,
|
||||
sk=sk,
|
||||
region=region,
|
||||
**kwargs)
|
||||
elif model_type.upper() == AICCModelType.ASR:
|
||||
return ASRClient(base_url=base_url,
|
||||
aicc_service_name=aicc_service_name,
|
||||
policy_id=policy_id,
|
||||
is_openai=is_openai,
|
||||
is_secure=is_secure,
|
||||
ak=ak,
|
||||
sk=sk,
|
||||
region=region,
|
||||
**kwargs)
|
||||
else:
|
||||
err_str = f"can not support model type:{model_type}"
|
||||
logger.error(err_str)
|
||||
raise AICCException(err_str)
|
||||
|
||||
@staticmethod
|
||||
def get_volcengine_ark(base_url: str = None, api_key: str = None, **kwargs):
|
||||
return Ark(api_key=api_key, base_url=base_url, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def hello() -> str:
|
||||
info = "hello volcengine_aicc_sdk"
|
||||
print(info)
|
||||
return info
|
||||
|
||||
def _init_config(self):
|
||||
pass
|
||||
|
||||
def _init_log(self):
|
||||
config = LogConfig()
|
||||
|
||||
if log_dir := self.config.get("dir", "aicc_log"):
|
||||
config.dir = log_dir
|
||||
|
||||
if filename := self.config.get("filename", "aicc.log"):
|
||||
config.filename = filename
|
||||
|
||||
if rotation := self.config.get("rotation", 100):
|
||||
config.rotation = rotation
|
||||
|
||||
if retention := self.config.get("retention", 30):
|
||||
config.retention = retention
|
||||
|
||||
if level := self.config.get("level"):
|
||||
config.level = level
|
||||
|
||||
logger.init_log_config(config)
|
||||
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
AICC机密推理客户端BASE
|
||||
"""
|
||||
__all__ = ["AICCInference"]
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import IO, Union
|
||||
|
||||
|
||||
class AICCInference(ABC):
|
||||
"""
|
||||
AICC机密推理访问客户端.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
base_url: str = None,
|
||||
is_openai: bool = True,
|
||||
is_secure: bool = True,
|
||||
ak: str = None,
|
||||
sk: str = None,
|
||||
region: str = "cn-beijing"
|
||||
):
|
||||
self.base_url = base_url
|
||||
self.is_openai = is_openai
|
||||
self.is_secure = is_secure
|
||||
self.ak = ak
|
||||
self.sk = sk
|
||||
self.region = region
|
||||
|
||||
@staticmethod
|
||||
def hello() -> str:
|
||||
info = "hello aicc inference"
|
||||
print(info)
|
||||
return info
|
||||
|
||||
@abstractmethod
|
||||
def chat_completions(self, messages: list, output_stream: IO = None) -> Union[str, None]:
|
||||
"""
|
||||
生成聊天补全
|
||||
:param messages:
|
||||
:param output_stream:是否启用流式响应
|
||||
:return:
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def function_call(self, messages: list, tools: list, output_stream: IO = None) -> Union[str, None]:
|
||||
"""
|
||||
生成聊天补全
|
||||
:param messages:
|
||||
:param tools:
|
||||
:param output_stream: 是否开启流式
|
||||
:return:
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def asr_recognize(self, file_name: str) -> Union[str, None]:
|
||||
"""
|
||||
ASR转文字
|
||||
:param file_name:
|
||||
:return:
|
||||
"""
|
||||
pass
|
||||
@@ -0,0 +1,343 @@
|
||||
"""
|
||||
机密豆包客户端
|
||||
"""
|
||||
__all__ = ["ARKClient"]
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
from typing import IO, Union
|
||||
|
||||
from volcenginesdkarkruntime import Ark
|
||||
from volcenginesdkarkruntime._utils._key_agreement import (
|
||||
aes_gcm_encrypt_base64_string,
|
||||
aes_gcm_decrypt_base64_string,
|
||||
key_agreement_client
|
||||
)
|
||||
from openai import OpenAI
|
||||
from volcenginesdkarkruntime.types.chat import ChatCompletion, ChatCompletionChunk
|
||||
|
||||
from .aicc_inference import AICCInference
|
||||
from .common.error import AICCException
|
||||
from .common.log import logger
|
||||
|
||||
|
||||
class ARKClient(AICCInference):
|
||||
"""
|
||||
机密豆包客户端.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
base_url: str = None,
|
||||
api_key: str = None,
|
||||
ep: str = None,
|
||||
is_openai: bool = False,
|
||||
is_secure: bool = True,
|
||||
thinking_type: str = "disabled",
|
||||
ak: str = None,
|
||||
sk: str = None,
|
||||
region: str = "cn-beijing",
|
||||
**kwargs):
|
||||
if not base_url or not api_key or not ep:
|
||||
raise AICCException(f"New ARK client failed, base_url={base_url}, api_key={api_key}, ep={ep}")
|
||||
|
||||
super().__init__(base_url, is_openai, is_secure, ak, sk, region)
|
||||
|
||||
os.environ["VOLC_ARK_ENCRYPTION"] = "AICC"
|
||||
|
||||
self.base_url = base_url
|
||||
self.api_key = api_key
|
||||
self.ep = ep
|
||||
self.is_openai = is_openai
|
||||
self.is_secure = is_secure
|
||||
self.thinking_type = thinking_type
|
||||
|
||||
self.kwargs = kwargs
|
||||
|
||||
self.client = None
|
||||
try:
|
||||
self.client = Ark(api_key=api_key, base_url=base_url, ak=ak, sk=sk, region=region)
|
||||
except Exception as e:
|
||||
logger.error(f"New ARK client failed: {e}")
|
||||
raise AICCException("New ARK client failed")
|
||||
|
||||
def _chat_completion_to_str(self, chat_completion: ChatCompletion) -> str:
|
||||
ret = dict()
|
||||
ret["id"] = chat_completion.id
|
||||
ret["object"] = chat_completion.object
|
||||
ret["created"] = chat_completion.created
|
||||
ret["model"] = chat_completion.model
|
||||
ret["service_tier"] = chat_completion.service_tier
|
||||
|
||||
if chat_completion.usage:
|
||||
ret["usage"] = json.loads(chat_completion.usage.json())
|
||||
else:
|
||||
ret["usage"] = None
|
||||
|
||||
ret["choices"] = list()
|
||||
for choice in chat_completion.choices:
|
||||
ret["choices"].append(json.loads(choice.json()))
|
||||
|
||||
return json.dumps(ret)
|
||||
|
||||
def _chat_completion_chunk_to_str(self, chat_completion_chunk: ChatCompletionChunk) -> str:
|
||||
ret = dict()
|
||||
ret["id"] = chat_completion_chunk.id
|
||||
ret["object"] = chat_completion_chunk.object
|
||||
ret["created"] = chat_completion_chunk.created
|
||||
ret["model"] = chat_completion_chunk.model
|
||||
ret["service_tier"] = chat_completion_chunk.service_tier
|
||||
if chat_completion_chunk.usage:
|
||||
ret["usage"] = json.loads(chat_completion_chunk.usage.json())
|
||||
else:
|
||||
ret["usage"] = None
|
||||
ret["choices"] = list()
|
||||
for choice in chat_completion_chunk.choices:
|
||||
ret["choices"].append(json.loads(choice.json()))
|
||||
|
||||
return json.dumps(ret)
|
||||
|
||||
def chat_completions(self, messages: list, output_stream: IO = None) -> Union[str, None]:
|
||||
"""
|
||||
:param messages:
|
||||
:param output_stream:
|
||||
:return:
|
||||
"""
|
||||
if not messages:
|
||||
raise AICCException("ARK completions, messages is NULL")
|
||||
|
||||
try:
|
||||
thinking = {
|
||||
"type": self.thinking_type
|
||||
}
|
||||
|
||||
include_usage = True if self.kwargs.get("include_usage", "True").lower() == "true" and output_stream \
|
||||
else False
|
||||
|
||||
if self.is_openai:
|
||||
openai_messages = copy.deepcopy(messages)
|
||||
|
||||
client = OpenAI(base_url=self.base_url, api_key=self.api_key)
|
||||
ark_client = Ark(base_url=self.base_url, api_key=self.api_key)
|
||||
|
||||
ark_certificate = ark_client._get_endpoint_certificate(self.ep)
|
||||
if isinstance(ark_certificate, tuple):
|
||||
cipher_client = ark_certificate[0]
|
||||
ring_id = ark_certificate[1]
|
||||
key_id = ark_certificate[2]
|
||||
key, nonce, token = cipher_client.generate_ecies_key_pair()
|
||||
elif isinstance(ark_certificate, key_agreement_client):
|
||||
ring_id, key_id = ark_certificate.get_cert_ring_key_id()
|
||||
key, nonce, token = ark_certificate.generate_ecies_key_pair()
|
||||
else:
|
||||
logger.error(f"get ark_certificate ERROR")
|
||||
raise AICCException("get ark_certificate ERROR")
|
||||
|
||||
info = {
|
||||
'Version': 'AICCv0.1',
|
||||
'RingID': ring_id,
|
||||
'KeyID': key_id,
|
||||
}
|
||||
|
||||
if self.is_secure:
|
||||
extra_headers = {
|
||||
"X-Session-Token": token,
|
||||
"x-ark-moderation-scene": "aicc-skip",
|
||||
"X-Encrypt-Info": json.dumps(info),
|
||||
}
|
||||
for i in range(len(openai_messages)):
|
||||
content = openai_messages[i].get("content")
|
||||
|
||||
if isinstance(content, str):
|
||||
openai_messages[i]["content"] = aes_gcm_encrypt_base64_string(key, nonce, content)
|
||||
elif isinstance(content, list):
|
||||
encrypted_content = []
|
||||
for item in content:
|
||||
if isinstance(item, dict) and "type" in item and item["type"] == "text":
|
||||
# 加密文本
|
||||
encrypted_item = item.copy()
|
||||
encrypted_item["text"] = aes_gcm_encrypt_base64_string(key, nonce, item["text"])
|
||||
encrypted_content.append(encrypted_item)
|
||||
elif isinstance(item, dict) and "type" in item and item["type"] == "image_url":
|
||||
# 加密图片
|
||||
encrypted_item = item.copy()
|
||||
encrypted_item["image_url"]["url"] = aes_gcm_encrypt_base64_string(
|
||||
key, nonce, item["image_url"]["url"])
|
||||
encrypted_content.append(encrypted_item)
|
||||
elif isinstance(item, dict) and "type" in item and item["type"] == "video_url":
|
||||
# 加密视频
|
||||
error_info = f"Sorry, 密文豆包不支持openai格式"
|
||||
logger.error(error_info)
|
||||
raise AICCException(error_info)
|
||||
|
||||
# encrypted_item = item.copy()
|
||||
# if isinstance(encrypted_item["video_url"], dict):
|
||||
# encrypted_item["video_url"]["url"] = aes_gcm_encrypt_base64_string(
|
||||
# key, nonce, item["video_url"]["url"])
|
||||
# else:
|
||||
# encrypted_item["video_url"] = aes_gcm_encrypt_base64_string(
|
||||
# key, nonce, item["video_url"])
|
||||
# encrypted_content.append(encrypted_item)
|
||||
else:
|
||||
encrypted_content.append(item)
|
||||
openai_messages[i]["content"] = encrypted_content
|
||||
else:
|
||||
raise AICCException("no support this content type")
|
||||
else:
|
||||
extra_headers = {
|
||||
}
|
||||
|
||||
completion = client.chat.completions.create(
|
||||
model=self.ep,
|
||||
messages=openai_messages,
|
||||
stream_options={
|
||||
"include_usage": include_usage
|
||||
},
|
||||
extra_headers=extra_headers,
|
||||
stream=True if output_stream else False,
|
||||
)
|
||||
if output_stream:
|
||||
for chunk in completion:
|
||||
if self.is_secure and chunk.choices and chunk.choices[0].delta.content:
|
||||
chunk.choices[0].delta.content = aes_gcm_decrypt_base64_string(
|
||||
key, nonce, chunk.choices[0].delta.content)
|
||||
output_stream.write(f"{self._chat_completion_chunk_to_str(chunk)}\n")
|
||||
else:
|
||||
if self.is_secure and completion.choices and completion.choices[0].message.content:
|
||||
completion.choices[0].message.content = \
|
||||
aes_gcm_decrypt_base64_string(key, nonce, completion.choices[0].message.content)
|
||||
cont = self._chat_completion_to_str(completion)
|
||||
return cont
|
||||
else:
|
||||
resp = self.client.chat.completions.create(
|
||||
model=self.ep,
|
||||
messages=messages,
|
||||
thinking=thinking,
|
||||
stream=True if output_stream else False,
|
||||
stream_options={
|
||||
"include_usage": include_usage
|
||||
},
|
||||
extra_headers={"x-is-encrypted": str(self.is_secure).lower(), "x-ark-moderation-scene": "aicc-skip"}
|
||||
)
|
||||
|
||||
if output_stream:
|
||||
for chunk in resp:
|
||||
output_stream.write(f"{self._chat_completion_chunk_to_str(chunk)}\n")
|
||||
else:
|
||||
return self._chat_completion_to_str(resp)
|
||||
except AICCException as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"ARK completions ERROR, e={e}")
|
||||
raise AICCException("ARK completions ERROR")
|
||||
|
||||
def chat_completions_stream(self, messages: list, output_stream: IO) -> Union[str, None]:
|
||||
return self.chat_completions(messages, output_stream)
|
||||
|
||||
def chat_completions_stream_yield(self, messages: list):
|
||||
"""
|
||||
:param messages:
|
||||
:return:
|
||||
"""
|
||||
if not messages:
|
||||
raise AICCException("ARK completions, messages is NULL")
|
||||
|
||||
try:
|
||||
thinking = {
|
||||
"type": self.thinking_type
|
||||
}
|
||||
|
||||
include_usage = True if self.kwargs.get("include_usage", "True").lower() == "true" else False
|
||||
|
||||
if self.is_openai:
|
||||
openai_messages = copy.deepcopy(messages)
|
||||
|
||||
client = OpenAI(base_url=self.base_url, api_key=self.api_key)
|
||||
ark_client = Ark(base_url=self.base_url, api_key=self.api_key)
|
||||
res_a = ark_client._get_endpoint_certificate(self.ep)
|
||||
cipher_client = res_a[0]
|
||||
ring_id = res_a[1]
|
||||
key_id = res_a[2]
|
||||
key, nonce, token = cipher_client.generate_ecies_key_pair()
|
||||
info = {
|
||||
'Version': 'AICCv0.1',
|
||||
'RingID': ring_id,
|
||||
'KeyID': key_id,
|
||||
}
|
||||
|
||||
if self.is_secure:
|
||||
extra_headers = {
|
||||
"X-Session-Token": token,
|
||||
"x-ark-moderation-scene": "aicc-skip",
|
||||
"X-Encrypt-Info": json.dumps(info),
|
||||
}
|
||||
for i in range(len(openai_messages)):
|
||||
openai_messages[i]["content"] = aes_gcm_encrypt_base64_string(
|
||||
key, nonce, openai_messages[i].get("content"))
|
||||
else:
|
||||
extra_headers = {
|
||||
}
|
||||
|
||||
completion = client.chat.completions.create(
|
||||
model=self.ep,
|
||||
messages=openai_messages,
|
||||
stream_options={
|
||||
"include_usage": include_usage
|
||||
},
|
||||
extra_headers=extra_headers,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
for chunk in completion:
|
||||
if self.is_secure and chunk.choices and chunk.choices[0].delta.content:
|
||||
chunk.choices[0].delta.content = aes_gcm_decrypt_base64_string(
|
||||
key, nonce, chunk.choices[0].delta.content)
|
||||
yield self._chat_completion_chunk_to_str(chunk)
|
||||
else:
|
||||
resp = self.client.chat.completions.create(
|
||||
model=self.ep,
|
||||
messages=messages,
|
||||
thinking=thinking,
|
||||
stream=True,
|
||||
stream_options={
|
||||
"include_usage": include_usage
|
||||
},
|
||||
extra_headers={"x-is-encrypted": str(self.is_secure).lower(), "x-ark-moderation-scene": "aicc-skip"}
|
||||
)
|
||||
|
||||
for chunk in resp:
|
||||
yield self._chat_completion_chunk_to_str(chunk)
|
||||
except Exception as e:
|
||||
logger.error(f"ARK completions ERROR, e={e}")
|
||||
raise AICCException("ARK completions ERROR")
|
||||
|
||||
def function_call(self, messages: list, tools: list, output_stream: IO = None) -> str:
|
||||
"""
|
||||
:param messages:
|
||||
:param tools:
|
||||
:param output_stream:
|
||||
:return:
|
||||
"""
|
||||
if not messages or not tools:
|
||||
raise AICCException("doubao function_call params cannot be empty")
|
||||
|
||||
try:
|
||||
resp = self.client.chat.completions.create(
|
||||
model=self.ep,
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
)
|
||||
return self._chat_completion_to_str(resp)
|
||||
except AICCException as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"call AICC ARK service function_call error: {e}")
|
||||
raise AICCException("AICC ARK service function_call error")
|
||||
|
||||
def function_call_stream(self, messages: list, tools: list, output_stream: IO) -> str:
|
||||
return self.function_call(messages, tools, output_stream)
|
||||
|
||||
def asr_recognize(self, file_name: str) -> str:
|
||||
error_info = "Sorry, this version does not support asr at this time."
|
||||
logger.error(error_info)
|
||||
return error_info
|
||||
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
DeepSeek机密推理客户端
|
||||
"""
|
||||
__all__ = ["ASRClient"]
|
||||
|
||||
import json
|
||||
import uuid
|
||||
import requests
|
||||
import bytedance.jeddak_secure_channel as jsc
|
||||
from typing import IO, Union
|
||||
from .aicc_inference import AICCInference
|
||||
from .common.constant import Constants
|
||||
from .common.error import AICCException
|
||||
from .common.log import logger
|
||||
from .common.utils import file_to_base64
|
||||
|
||||
|
||||
class ASRClient(AICCInference):
|
||||
"""
|
||||
ASR客户端.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
base_url: str = None,
|
||||
aicc_service_name: str = None,
|
||||
policy_id: str = None,
|
||||
is_openai: bool = True,
|
||||
is_secure: bool = True,
|
||||
ak: str = None,
|
||||
sk: str = None,
|
||||
region: str = "cn-beijing",
|
||||
**kwargs):
|
||||
appid = kwargs.get("appid")
|
||||
token = kwargs.get("token")
|
||||
service_id = kwargs.get("service_id")
|
||||
|
||||
if not base_url or not appid or not token or not service_id or not \
|
||||
aicc_service_name or not policy_id or not ak or not sk:
|
||||
raise AICCException("New deepseek client failed, params error")
|
||||
|
||||
super().__init__(base_url, is_openai, is_secure, ak, sk, region)
|
||||
|
||||
self.appid = appid
|
||||
self.token = token
|
||||
self.service_id = service_id
|
||||
|
||||
self.aicc_service_name = aicc_service_name
|
||||
self.policy_id = policy_id
|
||||
|
||||
self.kwargs = kwargs
|
||||
|
||||
self.jsc_client = None
|
||||
self.max_retries = 3
|
||||
|
||||
if is_secure:
|
||||
jsc_config = dict()
|
||||
jsc_config["ra_service_name"] = aicc_service_name
|
||||
jsc_config["ra_policy_id"] = policy_id
|
||||
jsc_config["bytedance_top_info"] = dict()
|
||||
jsc_config["bytedance_top_info"]["ak"] = ak
|
||||
jsc_config["bytedance_top_info"]["sk"] = sk
|
||||
jsc_config["bytedance_top_info"]["region"] = region
|
||||
jsc_config["bytedance_top_info"]["url"] = self.kwargs.get("top_url", "open.volcengineapi.com")
|
||||
jsc_config["bytedance_top_info"]["service"] = self.kwargs.get("top_service", "pcc")
|
||||
jsc_config["log_config"] = self.kwargs.get("log_config", Constants.LOG_CONFIG)
|
||||
|
||||
try:
|
||||
secure_channel_config = jsc.ClientConfig.from_dict(jsc_config)
|
||||
self.jsc_client = jsc.Client(secure_channel_config)
|
||||
except Exception as e:
|
||||
logger.error(f"Error init jsc: {e}")
|
||||
raise AICCException("New deepseek client failed")
|
||||
|
||||
def chat_completions(self, messages: list, output_stream: IO = None) -> Union[str, None]:
|
||||
"""
|
||||
生成聊天补全
|
||||
:param messages:
|
||||
:param output_stream:
|
||||
:return:
|
||||
"""
|
||||
pass
|
||||
|
||||
def function_call(self, messages: list, tools: list, output_stream: IO = None) -> str:
|
||||
"""
|
||||
:param messages:
|
||||
:param tools:
|
||||
:param output_stream:
|
||||
:return:
|
||||
"""
|
||||
pass
|
||||
|
||||
def asr_recognize(self, file_name: str) -> Union[str, None]:
|
||||
if not file_name:
|
||||
raise AICCException("ASR asr_recognize file_name cannot be empty")
|
||||
|
||||
appid = self.appid
|
||||
token = self.token
|
||||
service_id = self.service_id
|
||||
model_name = "bigmodel"
|
||||
|
||||
if self.base_url.endswith("flash"):
|
||||
recognize_url = self.base_url
|
||||
else:
|
||||
if self.is_secure:
|
||||
recognize_url = f"{self.base_url}/api/v2/auc/{model_name}/recognize/flash"
|
||||
else:
|
||||
recognize_url = f"{self.base_url}/api/v1/auc/{model_name}/recognize/flash"
|
||||
|
||||
try:
|
||||
audio_data = None
|
||||
enc_base64_data = file_to_base64(file_name) # 转换文件为 Base64
|
||||
|
||||
# 将转换后的base64数据写入文件
|
||||
enc_key = None
|
||||
if self.is_secure:
|
||||
enc_base64_data, enc_key = self.jsc_client.encrypt_with_response(enc_base64_data)
|
||||
|
||||
audio_data = {
|
||||
"data": enc_base64_data,
|
||||
}
|
||||
|
||||
headers = {
|
||||
"X-Api-App-Id": appid,
|
||||
"X-Api-App-Key": token,
|
||||
"X-Api-Aicc-Endpoint": service_id,
|
||||
"X-Api-Request-Id": str(uuid.uuid4()),
|
||||
}
|
||||
|
||||
if not audio_data:
|
||||
raise AICCException("必须提供ASR文件内容")
|
||||
|
||||
request = {
|
||||
"audio": audio_data,
|
||||
"request": {
|
||||
"model_name": model_name,
|
||||
},
|
||||
}
|
||||
|
||||
response = requests.post(recognize_url, json=request, headers=headers)
|
||||
if response.status_code == 200: # task finished
|
||||
result = response.json()
|
||||
content = result["result"]
|
||||
if self.is_secure and enc_key:
|
||||
content = enc_key.decrypt(content)
|
||||
# 检查 content 类型,如果是 bytes 则解码,否则直接使用
|
||||
if isinstance(content, bytes):
|
||||
content = json.loads(content.decode("utf-8"))
|
||||
else:
|
||||
content = json.loads(content)
|
||||
result["result"] = content
|
||||
return json.dumps(result)
|
||||
else:
|
||||
raise AICCException("ASR处理失败")
|
||||
except AICCException as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"call AICC ASR service asr_recognize error: {e}")
|
||||
raise AICCException("AICC ASR service asr_recognize error")
|
||||
@@ -0,0 +1,647 @@
|
||||
"""
|
||||
流式语音识别大模型机密推理客户端
|
||||
"""
|
||||
__all__ = ["AsrWebSocketClient"]
|
||||
|
||||
from ..jeddak_secure_channel.crypto import AesKey
|
||||
|
||||
"""
|
||||
SAUC WebSocket demo client.
|
||||
|
||||
Usage:
|
||||
1. Minimal run:
|
||||
python3 tests/sauc_websocket_demo.py --file tests/test-sound.mp3 --stop-on-first-result
|
||||
|
||||
2. Override any request fields with inline JSON:
|
||||
python3 tests/sauc_websocket_demo.py --file tests/test-sound.mp3 \
|
||||
--request-json '{"request":{"enable_nonstream":true,"show_utterances":true}}'
|
||||
|
||||
3. Override any request fields with a JSON file:
|
||||
python3 tests/sauc_websocket_demo.py --file tests/test-sound.mp3 \
|
||||
--request-json-file /path/to/sauc_request.json
|
||||
|
||||
Notes:
|
||||
- CLI keeps only commonly needed options.
|
||||
- Any SAUC full client request fields can still be passed through JSON overrides.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import gzip
|
||||
import subprocess
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
import wave
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Iterator, List, Optional, Tuple
|
||||
|
||||
try:
|
||||
from websocket import WebSocket, WebSocketConnectionClosedException, WebSocketTimeoutException, create_connection
|
||||
except ImportError:
|
||||
try:
|
||||
from websocket._core import WebSocket
|
||||
from websocket._exceptions import WebSocketConnectionClosedException, WebSocketTimeoutException
|
||||
from websocket._app import create_connection
|
||||
except ImportError:
|
||||
WebSocket = object
|
||||
WebSocketConnectionClosedException = ConnectionError
|
||||
WebSocketTimeoutException = TimeoutError
|
||||
create_connection = None
|
||||
import bytedance.jeddak_secure_channel as jsc
|
||||
from .common.log import logger
|
||||
from .secure_http_client import AICCConfig
|
||||
|
||||
# 常量定义
|
||||
DEFAULT_SAMPLE_RATE = 16000
|
||||
DEFAULT_MODEL_NAME = "bigmodel"
|
||||
INTER_PACKET_DRAIN_TIMEOUT_SECONDS = 0.02
|
||||
DEFAULT_FULL_CLIENT_REQUEST = {
|
||||
"audio": {
|
||||
"format": "wav",
|
||||
"codec": "raw",
|
||||
"rate": DEFAULT_SAMPLE_RATE,
|
||||
"bits": 16,
|
||||
"channel": 1,
|
||||
},
|
||||
"request": {
|
||||
"model_name": DEFAULT_MODEL_NAME,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class ProtocolVersion:
|
||||
V1 = 0b0001
|
||||
|
||||
|
||||
class MessageType:
|
||||
CLIENT_FULL_REQUEST = 0b0001
|
||||
CLIENT_AUDIO_ONLY_REQUEST = 0b0010
|
||||
SERVER_FULL_RESPONSE = 0b1001
|
||||
SERVER_ERROR_RESPONSE = 0b1111
|
||||
|
||||
|
||||
class MessageTypeSpecificFlags:
|
||||
NO_SEQUENCE = 0b0000
|
||||
POS_SEQUENCE = 0b0001
|
||||
NEG_SEQUENCE = 0b0010
|
||||
NEG_WITH_SEQUENCE = 0b0011
|
||||
|
||||
|
||||
class SerializationType:
|
||||
NO_SERIALIZATION = 0b0000
|
||||
JSON = 0b0001
|
||||
|
||||
|
||||
class CompressionType:
|
||||
GZIP = 0b0001
|
||||
|
||||
|
||||
class AsrRequestHeader:
|
||||
def __init__(self):
|
||||
self.message_type = MessageType.CLIENT_FULL_REQUEST
|
||||
self.message_type_specific_flags = MessageTypeSpecificFlags.POS_SEQUENCE
|
||||
self.serialization_type = SerializationType.JSON
|
||||
self.compression_type = CompressionType.GZIP
|
||||
self.reserved_data = bytes([0x00])
|
||||
|
||||
def with_message_type(self, message_type: int) -> "AsrRequestHeader":
|
||||
self.message_type = message_type
|
||||
return self
|
||||
|
||||
def with_message_type_specific_flags(self, flags: int) -> "AsrRequestHeader":
|
||||
self.message_type_specific_flags = flags
|
||||
return self
|
||||
|
||||
def with_serialization_type(self, serialization_type: int) -> "AsrRequestHeader":
|
||||
self.serialization_type = serialization_type
|
||||
return self
|
||||
|
||||
def with_compression_type(self, compression_type: int) -> "AsrRequestHeader":
|
||||
self.compression_type = compression_type
|
||||
return self
|
||||
|
||||
def with_reserved_data(self, reserved_data: bytes) -> "AsrRequestHeader":
|
||||
self.reserved_data = reserved_data
|
||||
return self
|
||||
|
||||
def to_bytes(self) -> bytes:
|
||||
header = bytearray()
|
||||
header.append((ProtocolVersion.V1 << 4) | 1)
|
||||
header.append((self.message_type << 4) | self.message_type_specific_flags)
|
||||
header.append((self.serialization_type << 4) | self.compression_type)
|
||||
header.extend(self.reserved_data)
|
||||
return bytes(header)
|
||||
|
||||
@staticmethod
|
||||
def default_header() -> "AsrRequestHeader":
|
||||
return AsrRequestHeader()
|
||||
|
||||
|
||||
@dataclass
|
||||
class AsrResponse:
|
||||
code: int = 0
|
||||
event: int = 0
|
||||
is_last_package: bool = False
|
||||
payload_sequence: int = 0
|
||||
payload_size: int = 0
|
||||
payload_msg: Optional[Dict[str, Any]] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"code": self.code,
|
||||
"event": self.event,
|
||||
"is_last_package": self.is_last_package,
|
||||
"payload_sequence": self.payload_sequence,
|
||||
"payload_size": self.payload_size,
|
||||
"payload_msg": self.payload_msg,
|
||||
}
|
||||
|
||||
def is_terminal(self) -> bool:
|
||||
return self.is_last_package or self.code != 0
|
||||
|
||||
|
||||
class _SessionContext:
|
||||
"""Holds per-execution state to support concurrent access."""
|
||||
|
||||
def __init__(self, client: "AsrWebSocketClient"):
|
||||
self.client = client
|
||||
self.conn: Optional[WebSocket] = None
|
||||
self.seq: int = 1
|
||||
self.encrypt_key = None
|
||||
self.aes_key = AesKey.generate() if client.jsc_client else None
|
||||
|
||||
def connect(self) -> None:
|
||||
headers = self.client._build_auth_headers(
|
||||
self.client.api_key, self.client.resource_id, self.client._build_connection_headers()
|
||||
)
|
||||
self.conn = create_connection(
|
||||
self.client.base_url,
|
||||
header=[f"{key}: {value}" for key, value in headers.items()],
|
||||
timeout=self.client.connect_timeout,
|
||||
)
|
||||
self.conn.settimeout(self.client.receive_timeout)
|
||||
logger.info("Connected to %s", self.client.base_url)
|
||||
|
||||
def close(self) -> None:
|
||||
if self.conn is not None:
|
||||
self.conn.close()
|
||||
self.conn = None
|
||||
|
||||
def encode_outbound_frame(self, payload: bytes) -> bytes:
|
||||
if self.client.jsc_client:
|
||||
msg, encrypt_key = self.client.jsc_client.encrypt_with_response(payload, self.aes_key)
|
||||
if encrypt_key:
|
||||
self.encrypt_key = encrypt_key
|
||||
if isinstance(msg, str):
|
||||
msg = msg.encode("utf-8")
|
||||
return msg
|
||||
else:
|
||||
return payload
|
||||
|
||||
def decode_inbound_frame(self, payload: bytes) -> bytes:
|
||||
if self.encrypt_key:
|
||||
msg = self.encrypt_key.decrypt(payload)
|
||||
if isinstance(msg, str):
|
||||
msg = msg.encode("utf-8")
|
||||
return msg
|
||||
else:
|
||||
return payload
|
||||
|
||||
def send_binary(self, payload: bytes) -> None:
|
||||
if self.conn is None:
|
||||
raise RuntimeError("WebSocket connection is not established")
|
||||
self.conn.send_binary(self.encode_outbound_frame(payload))
|
||||
|
||||
def recv_one(self, timeout: float) -> Optional[AsrResponse]:
|
||||
if self.conn is None:
|
||||
raise RuntimeError("WebSocket connection is not established")
|
||||
|
||||
self.conn.settimeout(timeout)
|
||||
try:
|
||||
message = self.conn.recv()
|
||||
except WebSocketTimeoutException:
|
||||
return None
|
||||
except WebSocketConnectionClosedException as exc:
|
||||
raise RuntimeError("WebSocket connection closed unexpectedly") from exc
|
||||
finally:
|
||||
self.conn.settimeout(self.client.receive_timeout)
|
||||
|
||||
if isinstance(message, str):
|
||||
logger.info("Received text message: %s", message)
|
||||
return None
|
||||
|
||||
return self.client._parse_response(self.decode_inbound_frame(message))
|
||||
|
||||
def drain_messages(self, timeout: float) -> Iterator[AsrResponse]:
|
||||
while True:
|
||||
response = self.recv_one(timeout)
|
||||
if response is None:
|
||||
return
|
||||
yield response
|
||||
timeout = 0.1
|
||||
|
||||
def send_full_client_request(self) -> Iterator[AsrResponse]:
|
||||
self.send_binary(
|
||||
self.client._build_full_client_request(
|
||||
self.seq,
|
||||
model_name=self.client.model_name,
|
||||
enable_nonstream=self.client.enable_nonstream,
|
||||
enable_itn=self.client.enable_itn,
|
||||
enable_punc=self.client.enable_punc,
|
||||
enable_ddc=self.client.enable_ddc,
|
||||
show_utterances=self.client.show_utterances,
|
||||
result_type=self.client.result_type,
|
||||
enable_accelerate_text=self.client.enable_accelerate_text,
|
||||
vad_segment_duration=self.client.vad_segment_duration,
|
||||
full_client_request_overrides=self.client.full_client_request_overrides,
|
||||
)
|
||||
)
|
||||
logger.info(f"Sent full client request with seq: {self.seq}")
|
||||
self.seq += 1
|
||||
yield from self.drain_messages(self.client.connect_timeout)
|
||||
|
||||
def send_audio_stream(self, content: bytes, segment_size: int) -> Iterator[AsrResponse]:
|
||||
audio_segments = self.client._split_audio(content, segment_size)
|
||||
total_segments = len(audio_segments)
|
||||
|
||||
for index, segment in enumerate(audio_segments):
|
||||
is_last = index == total_segments - 1
|
||||
request = self.client._build_audio_only_request(self.seq, segment, is_last=is_last)
|
||||
self.send_binary(request)
|
||||
logger.debug(f"Sent audio segment with seq: {self.seq} (last: {is_last})")
|
||||
if not is_last:
|
||||
self.seq += 1
|
||||
|
||||
if self.client.segment_duration > 0:
|
||||
time.sleep(self.client.segment_duration / 1000)
|
||||
|
||||
yield from self.drain_messages(INTER_PACKET_DRAIN_TIMEOUT_SECONDS)
|
||||
|
||||
def receive_final_messages(self) -> Iterator[AsrResponse]:
|
||||
deadline = time.monotonic() + self.client.final_wait_seconds
|
||||
while time.monotonic() < deadline:
|
||||
response = self.recv_one(min(self.client.receive_timeout, deadline - time.monotonic()))
|
||||
if response is None:
|
||||
continue
|
||||
yield response
|
||||
if response.is_terminal():
|
||||
return
|
||||
|
||||
|
||||
class AsrWebSocketClient:
|
||||
_jsc_client_cache: Dict[str, Any] = {}
|
||||
_jsc_client_lock = threading.Lock()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = "",
|
||||
api_key: str = "",
|
||||
endpoint: str = "",
|
||||
aicc_config: AICCConfig = None,
|
||||
**kwargs
|
||||
):
|
||||
self.base_url = base_url
|
||||
self.api_key = api_key
|
||||
self.endpoint = endpoint
|
||||
self.resource_id = self.endpoint
|
||||
self.aicc_config = aicc_config
|
||||
self.kwargs = kwargs
|
||||
|
||||
self.seq = self.kwargs.get("seq", 1)
|
||||
self.segment_duration = self.kwargs.get("segment_duration", 200)
|
||||
|
||||
self.model_name = self.kwargs.get("model_name", None)
|
||||
self.enable_nonstream = self.kwargs.get("enable_nonstream", None)
|
||||
self.enable_itn = self.kwargs.get("enable_itn", None)
|
||||
self.enable_punc = self.kwargs.get("enable_punc", None)
|
||||
self.enable_ddc = self.kwargs.get("enable_ddc", None)
|
||||
self.show_utterances = self.kwargs.get("show_utterances", None)
|
||||
self.result_type = self.kwargs.get("result_type", None)
|
||||
self.enable_accelerate_text = self.kwargs.get("enable_accelerate_text", None)
|
||||
self.vad_segment_duration = self.kwargs.get("vad_segment_duration", None)
|
||||
self.full_client_request_overrides = self.kwargs.get("full_client_request_overrides", {})
|
||||
self.extra_headers = self.kwargs.get("extra_headers", {})
|
||||
self.connect_timeout = self.kwargs.get("connect_timeout", 10.0)
|
||||
self.receive_timeout = self.kwargs.get("receive_timeout", 1.0)
|
||||
self.final_wait_seconds = self.kwargs.get("final_wait_seconds", 15.0)
|
||||
self.is_secure = self.kwargs.get("is_secure", True)
|
||||
|
||||
# 将http地址转成wss地址
|
||||
if self.base_url.startswith("https://"):
|
||||
self.base_url = self.base_url.replace("https", "wss")
|
||||
elif self.base_url.startswith("http://"):
|
||||
self.base_url = self.base_url.replace("http", "wss")
|
||||
|
||||
asr_suffix = self.kwargs.get("asr_suffix", "/sauc/bigmodel_async")
|
||||
if not self.base_url.endswith(asr_suffix):
|
||||
self.base_url = self.base_url + asr_suffix
|
||||
|
||||
self.jsc_client = None
|
||||
if not self.aicc_config:
|
||||
if self.is_secure:
|
||||
self.aicc_config = AICCConfig(ra_url=base_url, auth_token=api_key)
|
||||
if self.aicc_config:
|
||||
self.jsc_client = self._get_or_create_jsc_client(aicc_config=self.aicc_config)
|
||||
|
||||
self.AICC_SDK_VERSION = "0.1.0"
|
||||
|
||||
@classmethod
|
||||
def _get_or_create_jsc_client(cls, config_dict: Dict[str, Any] = None, aicc_config: AICCConfig = None) -> Any:
|
||||
if aicc_config is not None:
|
||||
config_dict = aicc_config.get_jsc_config()
|
||||
cache_key = json.dumps(config_dict, sort_keys=True)
|
||||
if cache_key in cls._jsc_client_cache:
|
||||
return cls._jsc_client_cache[cache_key]
|
||||
with cls._jsc_client_lock:
|
||||
if cache_key in cls._jsc_client_cache:
|
||||
return cls._jsc_client_cache[cache_key]
|
||||
secure_channel_config = jsc.ClientConfig.from_dict(config_dict)
|
||||
client = jsc.Client(secure_channel_config)
|
||||
cls._jsc_client_cache[cache_key] = client
|
||||
return client
|
||||
|
||||
def _build_auth_headers(
|
||||
self,
|
||||
api_key: str,
|
||||
resource_id: str,
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
) -> Dict[str, str]:
|
||||
headers = {
|
||||
"X-Api-Resource-Id": resource_id,
|
||||
"X-Api-Request-Id": str(uuid.uuid4()),
|
||||
"X-Api-Connect-Id": str(uuid.uuid4()),
|
||||
"X-Api-Sequence": "-1",
|
||||
"X-Api-Key": api_key,
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
}
|
||||
if extra_headers:
|
||||
headers.update(extra_headers)
|
||||
return headers
|
||||
|
||||
def _convert_audio_to_wav_bytes(self, audio_path: str, sample_rate: int = DEFAULT_SAMPLE_RATE) -> bytes:
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-v",
|
||||
"quiet",
|
||||
"-y",
|
||||
"-i",
|
||||
audio_path,
|
||||
"-acodec",
|
||||
"pcm_s16le",
|
||||
"-ac",
|
||||
"1",
|
||||
"-ar",
|
||||
str(sample_rate),
|
||||
"-f",
|
||||
"wav",
|
||||
"-",
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
message = exc.stderr.decode("utf-8", errors="replace")
|
||||
raise RuntimeError(f"Audio conversion failed: {message}") from exc
|
||||
|
||||
try:
|
||||
with open("converted.wav", "wb") as file_handle:
|
||||
file_handle.write(result.stdout)
|
||||
except OSError as exc:
|
||||
logger.warning("Failed to save converted.wav: %s", exc)
|
||||
|
||||
return result.stdout
|
||||
|
||||
def _is_wav_file(self, data: bytes) -> bool:
|
||||
if len(data) < 12:
|
||||
return False
|
||||
return data[:4] == b"RIFF" and data[8:12] == b"WAVE"
|
||||
|
||||
def _read_audio_data(self, file_path: str) -> bytes:
|
||||
with open(file_path, "rb") as file_handle:
|
||||
content = file_handle.read()
|
||||
|
||||
if self._is_wav_file(content):
|
||||
return content
|
||||
|
||||
logger.info("Converting audio to WAV format...")
|
||||
return self._convert_audio_to_wav_bytes(file_path, DEFAULT_SAMPLE_RATE)
|
||||
|
||||
def _read_wav_info(self, data: bytes) -> Tuple[int, int, int, int]:
|
||||
try:
|
||||
with wave.open(io.BytesIO(data), "rb") as wav_file:
|
||||
channel_num = wav_file.getnchannels()
|
||||
samp_width = wav_file.getsampwidth()
|
||||
frame_rate = wav_file.getframerate()
|
||||
frame_count = wav_file.getnframes()
|
||||
except wave.Error as exc:
|
||||
raise ValueError(f"Invalid WAV file: {exc}") from exc
|
||||
|
||||
return channel_num, samp_width, frame_rate, frame_count
|
||||
|
||||
def _get_segment_size(self, content: bytes) -> int:
|
||||
channel_num, samp_width, frame_rate, _ = self._read_wav_info(content)
|
||||
size_per_second = channel_num * samp_width * frame_rate
|
||||
return size_per_second * self.segment_duration // 1000
|
||||
|
||||
def _build_connection_headers(self) -> Dict[str, str]:
|
||||
headers = dict(self.extra_headers)
|
||||
if self.aicc_config:
|
||||
headers["X-AICC-Encryption-Enable"] = "true"
|
||||
headers["X-AICC-Encryption-SDK"] = "aicc"
|
||||
headers["X-AICC-Encryption-Version"] = self.AICC_SDK_VERSION
|
||||
return headers
|
||||
|
||||
def _gzip_compress(self, data: bytes) -> bytes:
|
||||
return gzip.compress(data)
|
||||
|
||||
def _gzip_decompress(self, data: bytes) -> bytes:
|
||||
return gzip.decompress(data)
|
||||
|
||||
def _parse_response(self, message: bytes) -> AsrResponse:
|
||||
if not message:
|
||||
raise ValueError("Cannot parse an empty websocket frame")
|
||||
|
||||
response = AsrResponse()
|
||||
header_size = message[0] & 0x0F
|
||||
message_type = message[1] >> 4
|
||||
message_type_specific_flags = message[1] & 0x0F
|
||||
serialization_method = message[2] >> 4
|
||||
message_compression = message[2] & 0x0F
|
||||
payload = message[header_size * 4:]
|
||||
|
||||
if message_type_specific_flags & 0x01:
|
||||
response.payload_sequence = struct.unpack(">i", payload[:4])[0]
|
||||
payload = payload[4:]
|
||||
if message_type_specific_flags & 0x02:
|
||||
response.is_last_package = True
|
||||
if message_type_specific_flags & 0x04:
|
||||
response.event = struct.unpack(">i", payload[:4])[0]
|
||||
payload = payload[4:]
|
||||
|
||||
if message_type == MessageType.SERVER_FULL_RESPONSE:
|
||||
response.payload_size = struct.unpack(">I", payload[:4])[0]
|
||||
payload = payload[4:]
|
||||
elif message_type == MessageType.SERVER_ERROR_RESPONSE:
|
||||
response.code = struct.unpack(">i", payload[:4])[0]
|
||||
response.payload_size = struct.unpack(">I", payload[4:8])[0]
|
||||
payload = payload[8:]
|
||||
|
||||
if not payload:
|
||||
return response
|
||||
|
||||
if message_compression == CompressionType.GZIP:
|
||||
try:
|
||||
payload = self._gzip_decompress(payload)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to decompress payload: %s", exc)
|
||||
return response
|
||||
|
||||
try:
|
||||
if serialization_method == SerializationType.JSON:
|
||||
response.payload_msg = json.loads(payload.decode("utf-8"))
|
||||
except Exception as exc:
|
||||
logger.error("Failed to parse payload: %s", exc)
|
||||
|
||||
return response
|
||||
|
||||
def _deep_merge_dict(self, base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
|
||||
merged = dict(base)
|
||||
for key, value in override.items():
|
||||
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
||||
merged[key] = self._deep_merge_dict(merged[key], value)
|
||||
else:
|
||||
merged[key] = value
|
||||
return merged
|
||||
|
||||
def _build_full_request_payload(
|
||||
self,
|
||||
*,
|
||||
model_name: Optional[str] = None,
|
||||
enable_nonstream: Optional[bool] = None,
|
||||
enable_itn: Optional[bool] = None,
|
||||
enable_punc: Optional[bool] = None,
|
||||
enable_ddc: Optional[bool] = None,
|
||||
show_utterances: Optional[bool] = None,
|
||||
result_type: Optional[str] = None,
|
||||
enable_accelerate_text: Optional[bool] = None,
|
||||
vad_segment_duration: Optional[int] = None,
|
||||
full_client_request_overrides: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
payload = self._deep_merge_dict(DEFAULT_FULL_CLIENT_REQUEST, full_client_request_overrides or {})
|
||||
request_payload = dict(payload.get("request", {}))
|
||||
|
||||
if model_name is not None:
|
||||
request_payload["model_name"] = model_name
|
||||
if enable_nonstream is not None:
|
||||
request_payload["enable_nonstream"] = enable_nonstream
|
||||
if enable_itn is not None:
|
||||
request_payload["enable_itn"] = enable_itn
|
||||
if enable_punc is not None:
|
||||
request_payload["enable_punc"] = enable_punc
|
||||
if enable_ddc is not None:
|
||||
request_payload["enable_ddc"] = enable_ddc
|
||||
if show_utterances is not None:
|
||||
request_payload["show_utterances"] = show_utterances
|
||||
if result_type is not None:
|
||||
request_payload["result_type"] = result_type
|
||||
if enable_accelerate_text is not None:
|
||||
request_payload["enable_accelerate_text"] = enable_accelerate_text
|
||||
if vad_segment_duration is not None:
|
||||
request_payload["vad_segment_duration"] = vad_segment_duration
|
||||
|
||||
payload["request"] = request_payload
|
||||
return payload
|
||||
|
||||
def _build_full_client_request(
|
||||
self,
|
||||
seq: int,
|
||||
*,
|
||||
model_name: Optional[str] = None,
|
||||
enable_nonstream: Optional[bool] = None,
|
||||
enable_itn: Optional[bool] = None,
|
||||
enable_punc: Optional[bool] = None,
|
||||
enable_ddc: Optional[bool] = None,
|
||||
show_utterances: Optional[bool] = None,
|
||||
result_type: Optional[str] = None,
|
||||
enable_accelerate_text: Optional[bool] = None,
|
||||
vad_segment_duration: Optional[int] = None,
|
||||
full_client_request_overrides: Optional[Dict[str, Any]] = None,
|
||||
) -> bytes:
|
||||
header = AsrRequestHeader.default_header().with_message_type_specific_flags(MessageTypeSpecificFlags.POS_SEQUENCE)
|
||||
payload = self._build_full_request_payload(
|
||||
model_name=model_name,
|
||||
enable_nonstream=enable_nonstream,
|
||||
enable_itn=enable_itn,
|
||||
enable_punc=enable_punc,
|
||||
enable_ddc=enable_ddc,
|
||||
show_utterances=show_utterances,
|
||||
result_type=result_type,
|
||||
enable_accelerate_text=enable_accelerate_text,
|
||||
vad_segment_duration=vad_segment_duration,
|
||||
full_client_request_overrides=full_client_request_overrides,
|
||||
)
|
||||
payload_bytes = json.dumps(payload).encode("utf-8")
|
||||
compressed_payload = self._gzip_compress(payload_bytes)
|
||||
|
||||
request = bytearray()
|
||||
request.extend(header.to_bytes())
|
||||
request.extend(struct.pack(">i", seq))
|
||||
request.extend(struct.pack(">I", len(compressed_payload)))
|
||||
request.extend(compressed_payload)
|
||||
return bytes(request)
|
||||
|
||||
def _split_audio(self, data: bytes, segment_size: int) -> List[bytes]:
|
||||
if segment_size <= 0:
|
||||
return []
|
||||
return [data[index:index + segment_size] for index in range(0, len(data), segment_size)]
|
||||
|
||||
def _build_audio_only_request(self, seq: int, segment: bytes, is_last: bool = False) -> bytes:
|
||||
header = AsrRequestHeader.default_header()
|
||||
if is_last:
|
||||
header.with_message_type_specific_flags(MessageTypeSpecificFlags.NEG_WITH_SEQUENCE)
|
||||
seq = -seq
|
||||
else:
|
||||
header.with_message_type_specific_flags(MessageTypeSpecificFlags.POS_SEQUENCE)
|
||||
header.with_message_type(MessageType.CLIENT_AUDIO_ONLY_REQUEST)
|
||||
|
||||
request = bytearray()
|
||||
request.extend(header.to_bytes())
|
||||
request.extend(struct.pack(">i", seq))
|
||||
|
||||
compressed_segment = self._gzip_compress(segment)
|
||||
request.extend(struct.pack(">I", len(compressed_segment)))
|
||||
request.extend(compressed_segment)
|
||||
return bytes(request)
|
||||
|
||||
def execute(self, file_path: str) -> Iterator[AsrResponse]:
|
||||
if not file_path:
|
||||
raise ValueError("File path is empty")
|
||||
if not self.base_url or not (self.base_url.startswith("wss://") or self.base_url.startswith("ws://")):
|
||||
raise ValueError("Base URL is empty or invalid (must start with wss:// or ws://)")
|
||||
if not self.endpoint:
|
||||
raise ValueError("Endpoint is empty")
|
||||
if not self.api_key:
|
||||
raise ValueError("API key is empty")
|
||||
|
||||
session = _SessionContext(self)
|
||||
|
||||
content = self._read_audio_data(file_path)
|
||||
segment_size = self._get_segment_size(content)
|
||||
|
||||
try:
|
||||
session.connect()
|
||||
|
||||
for response in session.send_full_client_request():
|
||||
yield response
|
||||
if response.is_terminal():
|
||||
return
|
||||
|
||||
for response in session.send_audio_stream(content, segment_size):
|
||||
yield response
|
||||
if response.is_terminal():
|
||||
return
|
||||
|
||||
yield from session.receive_final_messages()
|
||||
finally:
|
||||
session.close()
|
||||
@@ -0,0 +1,305 @@
|
||||
import uuid
|
||||
"""
|
||||
seedance机密推理客户端
|
||||
"""
|
||||
__all__ = ["AUCClient"]
|
||||
|
||||
import os
|
||||
import base64
|
||||
import requests
|
||||
import traceback
|
||||
from urllib.parse import unquote
|
||||
from cryptography.hazmat.primitives import serialization as crypto_serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.backends import default_backend as crypto_default_backend
|
||||
from bytedance.jeddak_secure_channel.crypto import PrivateKey
|
||||
from .secure_http_client import SecureHttpClient
|
||||
from .common.tos_utils import TOSUtils
|
||||
from .common.error import AICCException
|
||||
from .common.log import logger
|
||||
from .secure_http_client import AICCConfig
|
||||
from .common.aes import decrypt_file
|
||||
|
||||
|
||||
class AUCClient:
|
||||
"""
|
||||
AUC机密推理客户端.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
base_url: str = "",
|
||||
api_key: str = "",
|
||||
endpoint: str = "",
|
||||
aicc_config: AICCConfig = None,
|
||||
**kwargs):
|
||||
self.base_url = base_url
|
||||
self.api_key = api_key
|
||||
self.endpoint = endpoint
|
||||
self.aicc_config = aicc_config
|
||||
self.tos_utils = TOSUtils()
|
||||
self.kwargs = kwargs
|
||||
self.is_secure = self.kwargs.get("is_secure", True)
|
||||
|
||||
if aicc_config:
|
||||
self.secure_http_client = SecureHttpClient(aicc_config=aicc_config)
|
||||
else:
|
||||
if self.is_secure:
|
||||
aicc_config = AICCConfig(ra_url=base_url, auth_token=api_key)
|
||||
self.secure_http_client = SecureHttpClient(aicc_config=aicc_config)
|
||||
else:
|
||||
self.secure_http_client = SecureHttpClient()
|
||||
|
||||
# 是否开启AUC文件加密, 默认加密
|
||||
self.is_enable_auc_encrypt = str(self.kwargs.get("is_enable_auc_encrypt", True)).lower() == "true"
|
||||
|
||||
# AUC文件的存储位置, 目前仅支持火山TOS
|
||||
self.auc_file_storage_location = self.kwargs.get("auc_file_storage_location", "TOS").upper()
|
||||
|
||||
self.auc_file_encrypt_pub_key_content = None
|
||||
self.auc_file_encrypt_priv_key_content = None
|
||||
|
||||
self.auc_file_encrypt_priv_key: PrivateKey = None
|
||||
|
||||
self.timeout = self.kwargs.get("timeout", 120.0)
|
||||
|
||||
# API Endpoints
|
||||
self.CREATE_TASK_URL = "/auc/lark/submit"
|
||||
self.QUERY_TASK_URL = "/auc/lark/query"
|
||||
|
||||
def _build_common_headers(self, request_id: str) -> dict[str, str]:
|
||||
if not request_id:
|
||||
request_id = str(uuid.uuid4())
|
||||
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"X-Api-App-Key": self.endpoint,
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"X-Request-Id": request_id,
|
||||
}
|
||||
|
||||
def _get_base_url(self):
|
||||
"""
|
||||
Get base URL with trailing slash removed
|
||||
"""
|
||||
return self.base_url.rstrip("/")
|
||||
|
||||
def generate_key_pair(self, pub_key_path: str = "", priv_key_path: str = ""):
|
||||
"""
|
||||
Generate RSA 4096 key pair and save to files
|
||||
|
||||
Args:
|
||||
pub_key_path: Path to save public key
|
||||
priv_key_path: Path to save private key
|
||||
|
||||
Returns:
|
||||
Tuple of (public_key_path, private_key_path)
|
||||
"""
|
||||
try:
|
||||
# Generate RSA key pair
|
||||
key = rsa.generate_private_key(
|
||||
backend=crypto_default_backend(),
|
||||
public_exponent=65537,
|
||||
key_size=4096
|
||||
)
|
||||
|
||||
# Save private key
|
||||
private_key = key.private_bytes(
|
||||
crypto_serialization.Encoding.PEM,
|
||||
crypto_serialization.PrivateFormat.PKCS8,
|
||||
crypto_serialization.NoEncryption()
|
||||
)
|
||||
with open(priv_key_path, 'wb') as f:
|
||||
f.write(private_key)
|
||||
|
||||
# Save public key
|
||||
public_key = key.public_key().public_bytes(
|
||||
crypto_serialization.Encoding.PEM,
|
||||
crypto_serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
)
|
||||
with open(pub_key_path, 'wb') as f:
|
||||
f.write(public_key)
|
||||
except Exception as e:
|
||||
logger.error(f"[Error] Failed to generate key pair: {e}")
|
||||
raise AICCException("Failed to generate RSA key pair") from e
|
||||
|
||||
def set_file_encrypt_key(self, pub_key_path: str = "", priv_key_path: str = ""):
|
||||
"""
|
||||
Set auc file encrypt key
|
||||
Args:
|
||||
pub_key_path: Public key for encrypt
|
||||
priv_key_path: Private key for decrypt
|
||||
"""
|
||||
if os.path.exists(pub_key_path):
|
||||
with open(pub_key_path, 'r') as f:
|
||||
content = f.read()
|
||||
if '-----BEGIN' in content and '-----END' in content:
|
||||
self.auc_file_encrypt_pub_key_content = content
|
||||
|
||||
if os.path.exists(priv_key_path):
|
||||
with open(priv_key_path, 'r') as f:
|
||||
content = f.read()
|
||||
if '-----BEGIN' in content and '-----END' in content:
|
||||
self.auc_file_encrypt_priv_key_content = content
|
||||
self.auc_file_encrypt_priv_key = \
|
||||
PrivateKey.from_private_pem(self.auc_file_encrypt_priv_key_content)
|
||||
|
||||
def create_task(self, data: dict, custom_header: dict = None, request_id: str = "") -> str:
|
||||
"""
|
||||
:param data:
|
||||
:param custom_header:
|
||||
:return:
|
||||
"""
|
||||
if not data:
|
||||
raise AICCException("data must be provided")
|
||||
|
||||
if "model" not in data:
|
||||
data["model"] = self.endpoint
|
||||
|
||||
base_url = self._get_base_url()
|
||||
url = f"{base_url}{self.CREATE_TASK_URL}"
|
||||
|
||||
headers = self._build_common_headers(request_id)
|
||||
if custom_header:
|
||||
headers.update(custom_header)
|
||||
|
||||
# Add encryption headers if provided
|
||||
if self.is_enable_auc_encrypt:
|
||||
if not self.auc_file_encrypt_pub_key_content or not self.auc_file_encrypt_priv_key_content:
|
||||
raise AICCException("pub_key and priv_key must be provided when Is-Enable-Encrypt is 'true'")
|
||||
|
||||
headers["Enable-TOS-Content-Result-Encryption"] = str(self.is_enable_auc_encrypt).lower()
|
||||
headers["X-Encryption-Algorithm"] = "RSA_OAEP_4096_AES_256"
|
||||
if self.auc_file_encrypt_pub_key_content:
|
||||
# Base64 encode the PEM public key for safe HTTP header transmission
|
||||
# This avoids issues with newlines and special characters in headers
|
||||
pk_base64 = base64.b64encode(self.auc_file_encrypt_pub_key_content.encode()).decode()
|
||||
headers["PK"] = pk_base64
|
||||
|
||||
# logger.info(f"[Request] url: {url}, headers: {headers}, data: {data}")
|
||||
|
||||
response = self.secure_http_client.post(url, headers=headers, json=data, timeout=self.timeout)
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
task_id = result.get("Data", {}).get("TaskID", "")
|
||||
return task_id
|
||||
else:
|
||||
logger.error(f"[Error] url: {url}, response: {response.text}")
|
||||
return ""
|
||||
|
||||
def query_task(self, task_id: str, request_id: str = "") -> dict:
|
||||
"""
|
||||
Query auc task
|
||||
API: POST /auc/lark/query
|
||||
"""
|
||||
|
||||
if not task_id:
|
||||
raise AICCException("task_id must be provided")
|
||||
|
||||
base_url = self._get_base_url()
|
||||
url = f"{base_url}{self.QUERY_TASK_URL}"
|
||||
|
||||
headers = self._build_common_headers(request_id)
|
||||
payload = {"TaskID": task_id}
|
||||
|
||||
response = self.secure_http_client.post(url, headers=headers, json=payload, timeout=self.timeout)
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
return result
|
||||
else:
|
||||
try:
|
||||
result = response.json()
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"[Error] url: {url}, Exception={e}")
|
||||
raise AICCException("Failed to query auc task")
|
||||
|
||||
def download_file(self, task_id: str, auc_file_path: str) -> bool:
|
||||
"""
|
||||
Download and decrypt encrypted auc
|
||||
This function polls the task status until it succeeds, then downloads
|
||||
and decrypts the auc using the provided private key.
|
||||
Args:
|
||||
task_id: AUC generation task ID
|
||||
auc_file_path: Directory to save decrypted auc
|
||||
|
||||
Returns:
|
||||
True if download succeeded, False otherwise
|
||||
"""
|
||||
if not task_id:
|
||||
raise AICCException("task_id must be provided")
|
||||
|
||||
if not auc_file_path:
|
||||
raise AICCException("auc_file_path must be provided")
|
||||
|
||||
task_info = self.query_task(task_id)
|
||||
status = task_info.get("Data", {}).get("Status", "")
|
||||
if not status:
|
||||
logger.error(f"task status is null, task_id={task_id}, query_task return {task_info}")
|
||||
raise AICCException("task status is null")
|
||||
if status != "success":
|
||||
logger.info(f"task status is not success, task_id={task_id}, status={status}")
|
||||
return False
|
||||
|
||||
result = task_info.get("Data", {}).get("Result") or {}
|
||||
for label, key in (
|
||||
("AudioTranscriptionFile", "AudioTranscriptionFile"),
|
||||
("SummarizationFile", "SummarizationFile"),
|
||||
("ChapterFile", "ChapterFile"),
|
||||
("InformationExtractionFile", "InformationExtractionFile"),
|
||||
("TranslationFile", "TranslationFile"),
|
||||
):
|
||||
file_url = result.get(key, "")
|
||||
if file_url:
|
||||
local_file_path = f"./{auc_file_path}_{label}.json"
|
||||
self.download_file_by_url(file_url, local_file_path)
|
||||
|
||||
def download_file_by_url(self, file_url: str, local_file_path: str) -> bool:
|
||||
"""
|
||||
Download auc file
|
||||
Args:
|
||||
file_url: File file_url
|
||||
auc_file_path: Directory to save auc file
|
||||
|
||||
Returns:
|
||||
Path to auc file, or None if failed
|
||||
"""
|
||||
if not file_url:
|
||||
raise AICCException("file_url must be provided")
|
||||
|
||||
if not local_file_path:
|
||||
raise AICCException("local_file_path must be provided")
|
||||
|
||||
if os.path.exists(local_file_path) and os.path.getsize(local_file_path) > 0:
|
||||
logger.error(f"[Error] File {local_file_path} already exists and is not empty")
|
||||
return False
|
||||
|
||||
try:
|
||||
response = requests.get(file_url, stream=True)
|
||||
with open(local_file_path, "wb") as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
|
||||
if self.is_enable_auc_encrypt:
|
||||
if not self.auc_file_encrypt_priv_key:
|
||||
logger.error("[Error] No private key set, cannot decrypt encrypted auc")
|
||||
return False
|
||||
|
||||
headers = response.headers
|
||||
encrypted_key = headers.get("x-tos-meta-enc-dek")
|
||||
if encrypted_key:
|
||||
try:
|
||||
encrypted_key_bytes = base64.b64decode(unquote(encrypted_key))
|
||||
encrypted_key = self.auc_file_encrypt_priv_key.decrypt(encrypted_key_bytes)
|
||||
tmp_file_path = local_file_path + ".tmp"
|
||||
decrypt_file(encrypted_key, local_file_path, tmp_file_path, 'b')
|
||||
os.remove(local_file_path)
|
||||
os.rename(tmp_file_path, local_file_path)
|
||||
except Exception as e:
|
||||
logger.error(f"[Error] Failed to decode encrypted_key: {e}")
|
||||
traceback.print_exc()
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"[Error] Download failed: {e}")
|
||||
traceback.print_exc()
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,136 @@
|
||||
__all__ = [
|
||||
"gcm_encrypt",
|
||||
"gcm_decrypt",
|
||||
"aes_encrypt",
|
||||
"aes_decrypt",
|
||||
"encrypt_file",
|
||||
"decrypt_file"
|
||||
]
|
||||
|
||||
import base64
|
||||
from typing_extensions import IO, Union
|
||||
|
||||
import secrets
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
|
||||
|
||||
SALT = "ai-handler-salt"
|
||||
|
||||
|
||||
def gcm_decrypt(key: bytes, ciphertext: bytes) -> bytes:
|
||||
iv, ciphertext = ciphertext[:12], ciphertext[12:]
|
||||
ciphertext, tag = ciphertext[:-16], ciphertext[-16:]
|
||||
decryptor = Cipher(
|
||||
algorithms.AES(key),
|
||||
modes.GCM(iv, tag),
|
||||
).decryptor()
|
||||
|
||||
return decryptor.update(ciphertext) + decryptor.finalize()
|
||||
|
||||
|
||||
def gcm_encrypt(key: bytes, plaintext: bytes) -> bytes:
|
||||
iv = secrets.token_bytes(12)
|
||||
encryptor = Cipher(
|
||||
algorithms.AES(key),
|
||||
modes.GCM(iv),
|
||||
).encryptor()
|
||||
|
||||
ciphertext = encryptor.update(plaintext) + encryptor.finalize()
|
||||
return iv + ciphertext + encryptor.tag
|
||||
|
||||
|
||||
def generate_key_from_string(seed_string: str) -> bytes:
|
||||
backend = default_backend()
|
||||
# 将字符串种子转换为字节
|
||||
password = seed_string.encode()
|
||||
salt = SALT.encode()
|
||||
kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA256(), length=32, salt=salt, iterations=100000, backend=backend
|
||||
)
|
||||
key = kdf.derive(password)
|
||||
return key
|
||||
|
||||
|
||||
def aes_encrypt(aes_key: Union[bytes, str], data: Union[bytes, str]) -> bytes:
|
||||
if isinstance(aes_key, str):
|
||||
aes_key = aes_key.encode()
|
||||
if isinstance(data, str):
|
||||
data = data.encode()
|
||||
return gcm_encrypt(aes_key, data)
|
||||
|
||||
|
||||
def aes_decrypt(aes_key: Union[bytes, str], data: Union[bytes, str]) -> bytes:
|
||||
if isinstance(aes_key, str):
|
||||
aes_key = aes_key.encode()
|
||||
if isinstance(data, str):
|
||||
data = data.encode()
|
||||
return gcm_decrypt(aes_key, data)
|
||||
|
||||
|
||||
def encrypt_file(
|
||||
aes_key: Union[bytes, str],
|
||||
source_path: str,
|
||||
dest_path: str,
|
||||
mode: str = "b",
|
||||
) -> bool:
|
||||
if mode not in ["b", "t"]:
|
||||
raise Exception("mode argument error")
|
||||
|
||||
if isinstance(aes_key, str):
|
||||
aes_key = aes_key.encode()
|
||||
|
||||
with open(source_path, f"r{mode}") as source, open(
|
||||
dest_path, f"w{mode}"
|
||||
) as dest:
|
||||
if mode == "b":
|
||||
source_b: IO[bytes] = source
|
||||
dest_b: IO[bytes] = dest
|
||||
plaintext = source_b.read()
|
||||
ciphertext = aes_encrypt(aes_key, plaintext)
|
||||
dest_b.write(ciphertext)
|
||||
else:
|
||||
source_t: IO[str] = source
|
||||
dest_t: IO[str] = dest
|
||||
for plaintext in source_t:
|
||||
plaintext = plaintext.rstrip("\n")
|
||||
ciphertext = aes_encrypt(aes_key, plaintext)
|
||||
dest_t.write(base64.b64encode(ciphertext).decode() + "\n")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def decrypt_file(
|
||||
aes_key: Union[bytes, str],
|
||||
source_path: str,
|
||||
dest_path: str,
|
||||
mode: str = "b",
|
||||
) -> bool:
|
||||
if mode not in ["b", "t"]:
|
||||
raise Exception("mode argument error")
|
||||
|
||||
if isinstance(aes_key, str):
|
||||
aes_key = aes_key.encode()
|
||||
|
||||
with open(source_path, f"r{mode}") as source, open(
|
||||
dest_path, f"w{mode}"
|
||||
) as dest:
|
||||
if mode == "b":
|
||||
source_b: IO[bytes] = source
|
||||
dest_b: IO[bytes] = dest
|
||||
ciphertext = source_b.read()
|
||||
plaintext = aes_decrypt(aes_key, ciphertext)
|
||||
dest_b.write(plaintext)
|
||||
else:
|
||||
source_t: IO[str] = source
|
||||
dest_t: IO[str] = dest
|
||||
for ciphertext in source_t:
|
||||
ciphertext = ciphertext.rstrip("\n")
|
||||
ciphertext = base64.b64decode(ciphertext.encode())
|
||||
plaintext = aes_decrypt(aes_key, ciphertext)
|
||||
dest_t.write(plaintext.decode() + "\n")
|
||||
# dest_t.write(base64.b64encode(plaintext).decode() + "\n")
|
||||
return True
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
class Constants:
|
||||
LOG_CONFIG = "{\"dir\": \"aicc_log\"}"
|
||||
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
安全通信错误类型.
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
"AICCException"
|
||||
]
|
||||
|
||||
|
||||
class AICCException(Exception):
|
||||
"""AICC 异常"""
|
||||
|
||||
def __init__(self, msg):
|
||||
Exception.__init__(self)
|
||||
self._msg = msg
|
||||
|
||||
def get_msg(self):
|
||||
return self._msg
|
||||
|
||||
def __str__(self):
|
||||
return self.get_msg()
|
||||
@@ -0,0 +1,24 @@
|
||||
__all__ = [
|
||||
"DEBUG",
|
||||
"INFO",
|
||||
"WARNING",
|
||||
"ERROR",
|
||||
"CRITICAL",
|
||||
"FORMAT",
|
||||
"LogConfig",
|
||||
"debug",
|
||||
"info",
|
||||
"warning",
|
||||
"error",
|
||||
"critical",
|
||||
"init_log_config"
|
||||
]
|
||||
|
||||
from .config import DEBUG, INFO, WARNING, ERROR, CRITICAL, FORMAT, LogConfig
|
||||
from .logger import init_log_config, info, debug, warning, error, critical
|
||||
|
||||
_initialized = False
|
||||
if not _initialized:
|
||||
config = LogConfig()
|
||||
init_log_config(config)
|
||||
_initialized = True
|
||||
@@ -0,0 +1,31 @@
|
||||
__all__ = [
|
||||
"DEBUG",
|
||||
"INFO",
|
||||
"WARNING",
|
||||
"ERROR",
|
||||
"CRITICAL",
|
||||
"FORMAT",
|
||||
"LogConfig"
|
||||
]
|
||||
|
||||
# Log level constants
|
||||
DEBUG = "DEBUG"
|
||||
INFO = "INFO"
|
||||
WARNING = "WARNING"
|
||||
ERROR = "ERROR"
|
||||
CRITICAL = "CRITICAL"
|
||||
# Log output format
|
||||
FORMAT = "{time:YYYY-MM-DD HH:mm:ss} | {level} | {file.path}:{line} | {message}"
|
||||
|
||||
|
||||
class LogConfig:
|
||||
"""Log configuration class."""
|
||||
|
||||
def __init__(self):
|
||||
self.rotation = 100 # Size limit for log files in MB
|
||||
self.retention = 7 # Days to retain log files
|
||||
self.compression = "zip"
|
||||
self.dir = "aicc_log"
|
||||
self.filename = "aicc.log"
|
||||
self.format = FORMAT
|
||||
self.level = INFO
|
||||
@@ -0,0 +1,88 @@
|
||||
__all__ = [
|
||||
"debug",
|
||||
"info",
|
||||
"warning",
|
||||
"error",
|
||||
"critical",
|
||||
"init_log_config"
|
||||
]
|
||||
|
||||
import os.path
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from . import LogConfig, FORMAT
|
||||
|
||||
|
||||
def init_log_config(*configs: LogConfig):
|
||||
"""Initialize logging configuration with given configs."""
|
||||
logger.remove()
|
||||
|
||||
log_level = "INFO"
|
||||
if os.environ.get("log_level"):
|
||||
log_level = os.environ.get("log_level")
|
||||
if os.environ.get("LOG_LEVEL"):
|
||||
log_level = os.environ.get("LOG_LEVEL")
|
||||
|
||||
logger.add(sink=sys.stdout, format=FORMAT, level=log_level)
|
||||
|
||||
for config in configs:
|
||||
if not config.dir:
|
||||
config.dir = os.environ.get("LOG_DIR", "aicc_log")
|
||||
|
||||
if not config.filename:
|
||||
config.filename = "aicc.log"
|
||||
|
||||
os.makedirs(config.dir, exist_ok=True)
|
||||
fullpath = os.path.join(config.dir, config.filename)
|
||||
|
||||
if config.rotation <= 0:
|
||||
config.rotation = 100
|
||||
rotation = f"{config.rotation} MB"
|
||||
|
||||
if config.retention <= 0:
|
||||
config.retention = 7
|
||||
retention = f"{config.retention} days"
|
||||
|
||||
logger.add(
|
||||
sink=fullpath,
|
||||
rotation=rotation,
|
||||
retention=retention,
|
||||
compression=config.compression,
|
||||
format=config.format,
|
||||
level=config.level
|
||||
)
|
||||
|
||||
|
||||
def msg_proc(message: str) -> str:
|
||||
if message:
|
||||
return f"[aicc]:{message}"
|
||||
else:
|
||||
return message
|
||||
|
||||
|
||||
def debug(message: str, *args: Any, **kwargs: Any) -> None:
|
||||
"""Log debug message."""
|
||||
logger.opt(depth=1).debug(msg_proc(message), *args, **kwargs)
|
||||
|
||||
|
||||
def info(message: str, *args: Any, **kwargs: Any) -> None:
|
||||
"""Log info message."""
|
||||
logger.opt(depth=1).info(msg_proc(message), *args, **kwargs)
|
||||
|
||||
|
||||
def warning(message: str, *args: Any, **kwargs: Any) -> None:
|
||||
"""Log warning message."""
|
||||
logger.opt(depth=1).warning(msg_proc(message), *args, **kwargs)
|
||||
|
||||
|
||||
def error(message: str, *args: Any, **kwargs: Any) -> None:
|
||||
"""Log error message."""
|
||||
logger.opt(depth=1).error(msg_proc(message), *args, **kwargs)
|
||||
|
||||
|
||||
def critical(message: str, *args: Any, **kwargs: Any) -> None:
|
||||
"""Log critical message."""
|
||||
logger.opt(depth=1).critical(msg_proc(message), *args, **kwargs)
|
||||
@@ -0,0 +1,341 @@
|
||||
__all__ = ["TOSUtils"]
|
||||
|
||||
from urllib import request
|
||||
from .aes import decrypt_file
|
||||
import os
|
||||
import wget
|
||||
import time
|
||||
import httpx
|
||||
from .error import AICCException
|
||||
from .log import logger
|
||||
|
||||
|
||||
class TOSUtils:
|
||||
def __init__(self, workdir: str = "./tmp"):
|
||||
self.workdir = workdir
|
||||
if not os.path.exists(self.workdir):
|
||||
os.makedirs(self.workdir, exist_ok=True)
|
||||
|
||||
self.default_large_file_threshold = 100 * 1024 * 1024
|
||||
self.default_download_timeout = 300.0
|
||||
self.default_chunk_size = 1024 * 1024
|
||||
|
||||
self.max_retries = 3
|
||||
self.retry_delay = 2 # seconds
|
||||
|
||||
def _download_small_file_wget(self, tos_url: str, local_file_path: str) -> bool:
|
||||
"""
|
||||
Download small files using Python wget module
|
||||
|
||||
Args:
|
||||
tos_url: TOS文件路径
|
||||
local_file_path: 本地文件路径
|
||||
|
||||
Returns:
|
||||
bool: 是否下载成功
|
||||
"""
|
||||
if not tos_url or not local_file_path:
|
||||
raise AICCException("TOS URL and local file path are required")
|
||||
|
||||
if os.path.exists(local_file_path):
|
||||
raise AICCException("Local file already exists, please delete it first")
|
||||
|
||||
for attempt in range(1, self.max_retries + 1):
|
||||
try:
|
||||
logger.info(f"Download attempt {attempt}/{self.max_retries}")
|
||||
|
||||
# Download file using wget
|
||||
wget.download(tos_url, local_file_path)
|
||||
|
||||
# Verify file was downloaded
|
||||
if not os.path.exists(local_file_path):
|
||||
raise AICCException("File download failed, try again...")
|
||||
|
||||
file_size = os.path.getsize(local_file_path)
|
||||
if file_size == 0:
|
||||
raise AICCException("Downloaded file is empty, try again...")
|
||||
|
||||
logger.info(f"Download successful, file size: {file_size} bytes")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Download attempt {attempt} failed: {str(e)}, try again...")
|
||||
if os.path.exists(local_file_path):
|
||||
try:
|
||||
os.remove(local_file_path)
|
||||
except:
|
||||
pass
|
||||
|
||||
if attempt == self.max_retries:
|
||||
raise AICCException(
|
||||
f"Failed to download encrypted file after {self.max_retries} attempts\n"
|
||||
f"Last error: {str(e)}\n"
|
||||
"Please check:\n"
|
||||
" 1. TOS URL is accessible\n"
|
||||
" 2. Network connection is stable\n"
|
||||
" 3. File exists on TOS"
|
||||
)
|
||||
|
||||
logger.info(f"Waiting {self.retry_delay} seconds before retry...")
|
||||
time.sleep(self.retry_delay)
|
||||
self.retry_delay *= 2
|
||||
|
||||
raise AICCException("Download failed unexpectedly")
|
||||
|
||||
def _download_small_file_httpx(self, tos_url: str, local_file_path: str) -> bool:
|
||||
"""
|
||||
Download small files using httpx (fallback when wget is not available)
|
||||
|
||||
Args:
|
||||
tos_url: TOS文件路径
|
||||
local_file_path: Path to save the downloaded file
|
||||
Returns:
|
||||
bool: 是否下载成功
|
||||
"""
|
||||
if not tos_url or not local_file_path:
|
||||
raise AICCException("TOS URL and local file path are required")
|
||||
|
||||
if os.path.exists(local_file_path):
|
||||
raise AICCException("Local file already exists, please delete it first")
|
||||
for attempt in range(1, self.max_retries + 1):
|
||||
try:
|
||||
logger.info(f"Download attempt {attempt}/{self.max_retries}")
|
||||
|
||||
# Download file using httpx
|
||||
with httpx.stream('GET', tos_url, timeout=self.default_download_timeout, follow_redirects=True) as response:
|
||||
response.raise_for_status()
|
||||
|
||||
with open(local_file_path, 'wb') as f:
|
||||
for chunk in response.iter_bytes(chunk_size=self.default_chunk_size):
|
||||
f.write(chunk)
|
||||
|
||||
# Verify file was downloaded
|
||||
if not os.path.exists(local_file_path):
|
||||
raise AICCException("File download failed, try again...")
|
||||
|
||||
file_size = os.path.getsize(local_file_path)
|
||||
if file_size == 0:
|
||||
raise AICCException("Downloaded file is empty")
|
||||
|
||||
logger.info(f"Download successful, file size: {file_size} bytes")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Download attempt {attempt} failed: {str(e)}")
|
||||
|
||||
# Clean up failed download
|
||||
if os.path.exists(local_file_path):
|
||||
try:
|
||||
os.remove(local_file_path)
|
||||
except:
|
||||
pass
|
||||
|
||||
# If this was the last attempt, raise error
|
||||
if attempt == self.max_retries:
|
||||
raise AICCException(
|
||||
f"Failed to download encrypted file after {self.max_retries} attempts\n"
|
||||
f"Last error: {str(e)}\n"
|
||||
"Please check:\n"
|
||||
" 1. TOS URL is accessible\n"
|
||||
" 2. Network connection is stable\n"
|
||||
" 3. File exists on TOS"
|
||||
)
|
||||
|
||||
# Wait before retry
|
||||
logger.info(f"Waiting {self.retry_delay} seconds before retry...")
|
||||
time.sleep(self.retry_delay)
|
||||
self.retry_delay *= 2
|
||||
|
||||
# Should never reach here, but satisfy type checker
|
||||
raise AICCException("Download failed unexpectedly")
|
||||
|
||||
def _download_large_file_streaming(self, tos_url: str, local_file_path: str, expected_size: int) -> bool:
|
||||
"""
|
||||
Download large files using httpx streaming
|
||||
|
||||
Args:
|
||||
tos_url: TOS文件路径
|
||||
local_file_path: Path to save the downloaded file
|
||||
expected_size: Expected file size in bytes
|
||||
|
||||
Returns:
|
||||
Path to the downloaded file
|
||||
"""
|
||||
if not tos_url or not local_file_path or expected_size <= 0:
|
||||
raise AICCException("TOS URL, local file path, and expected file size are required and must be greater than 0")
|
||||
|
||||
if os.path.exists(local_file_path):
|
||||
raise AICCException("Local file already exists, please delete it first")
|
||||
|
||||
for attempt in range(1, self.max_retries + 1):
|
||||
try:
|
||||
logger.info(f"Streaming download attempt {attempt}/{self.max_retries}")
|
||||
|
||||
# Streaming download with timeout
|
||||
with httpx.stream('GET', tos_url, timeout=self.default_download_timeout, follow_redirects=True) as response:
|
||||
response.raise_for_status()
|
||||
|
||||
downloaded_bytes = 0
|
||||
|
||||
with open(local_file_path, 'wb') as f:
|
||||
for chunk in response.iter_bytes(chunk_size=self.default_chunk_size):
|
||||
f.write(chunk)
|
||||
downloaded_bytes += len(chunk)
|
||||
|
||||
# Log progress every 10MB
|
||||
if downloaded_bytes % (10 * 1024 * 1024) < self.default_chunk_size:
|
||||
progress = (downloaded_bytes / expected_size) * 100 if expected_size > 0 else 0
|
||||
logger.info(f"Download progress: {progress:.1f}% ({downloaded_bytes / (1024*1024):.1f} MB / {expected_size / (1024*1024):.1f} MB)")
|
||||
|
||||
# Verify file was downloaded
|
||||
if not os.path.exists(local_file_path):
|
||||
raise AICCException("File download failed, try again...")
|
||||
|
||||
file_size = os.path.getsize(local_file_path)
|
||||
if file_size == 0:
|
||||
raise AICCException("Downloaded file is empty")
|
||||
|
||||
logger.info(f"Download successful, file size: {file_size} bytes")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Streaming download attempt {attempt} failed: {str(e)}")
|
||||
|
||||
# Clean up failed download
|
||||
if os.path.exists(local_file_path):
|
||||
try:
|
||||
os.remove(local_file_path)
|
||||
except:
|
||||
pass
|
||||
|
||||
# If this was the last attempt, raise error
|
||||
if attempt == self.max_retries:
|
||||
raise AICCException(
|
||||
f"Failed to download large file after {self.max_retries} attempts\n"
|
||||
f"Last error: {str(e)}\n"
|
||||
"Please check:\n"
|
||||
" 1. TOS URL is accessible\n"
|
||||
" 2. Network connection is stable\n"
|
||||
" 3. File exists on TOS\n"
|
||||
" 4. Timeout is sufficient for file size"
|
||||
)
|
||||
|
||||
# Wait before retry
|
||||
logger.info(f"Waiting {self.retry_delay} seconds before retry...")
|
||||
time.sleep(self.retry_delay)
|
||||
self.retry_delay *= 2
|
||||
|
||||
# Should never reach here, but satisfy type checker
|
||||
raise AICCException("Download failed unexpectedly")
|
||||
|
||||
def _extract_filename_from_url(self, url: str) -> str:
|
||||
"""
|
||||
Extract filename from TOS URL
|
||||
|
||||
Args:
|
||||
url: TOS URL
|
||||
|
||||
Returns:
|
||||
Filename
|
||||
"""
|
||||
# Remove query parameters
|
||||
path = url.split('?')[0]
|
||||
filename = os.path.basename(path)
|
||||
return filename
|
||||
|
||||
def download_file(self, tos_url: str, local_file_path: str, encrypted_key: bytes) -> bool:
|
||||
"""
|
||||
下载文件到本地路径, encrypted_key有值时需要先解密文件, 再保存到本地路径
|
||||
:return: 是否成功
|
||||
:param tos_url: TOS文件路径
|
||||
:param local_file_path: 本地文件路径
|
||||
:param encrypted_key: 加密密钥, 为空时表示文件未加密
|
||||
"""
|
||||
if not tos_url or not local_file_path:
|
||||
raise AICCException("TOS URL and local file path are required")
|
||||
|
||||
if os.path.exists(local_file_path):
|
||||
raise AICCException("Local file already exists, please delete it first")
|
||||
|
||||
if not tos_url.startswith('http'):
|
||||
raise AICCException(f"Invalid TOS URL: {tos_url}\n URL must start with http:// or https://")
|
||||
|
||||
try:
|
||||
if not self._download_small_file_wget(tos_url, local_file_path):
|
||||
raise AICCException("download file from TOS failed failed, please check the URL")
|
||||
|
||||
if encrypted_key:
|
||||
tmp_file_path = local_file_path + ".tmp"
|
||||
decrypt_file(encrypted_key, local_file_path, tmp_file_path, mode="b")
|
||||
os.remove(local_file_path)
|
||||
os.rename(tmp_file_path, local_file_path)
|
||||
|
||||
return True
|
||||
|
||||
except AICCException as e:
|
||||
logger.error(f"Error extracting filename from TOS URL: {e}")
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Error extracting filename from TOS URL: {str(e)}")
|
||||
raise AICCException("download filename from TOS file failed, please check the URL")
|
||||
|
||||
def download_files(self, tos_url: str, local_file_path: str, encrypted_key: bytes) -> bool:
|
||||
"""
|
||||
下载多个文件并合并到,local_file_path, encrypted_key有值时需要先解密文件, 再保存
|
||||
:return: 是否成功
|
||||
:param tos_url: TOS文件路径
|
||||
:param local_file_path: 本地文件路径
|
||||
:param encrypted_key: AES加密密钥, 为空时表示文件未加密
|
||||
"""
|
||||
if not tos_url or not local_file_path:
|
||||
raise AICCException("TOS URL and local file path are required")
|
||||
|
||||
if os.path.exists(local_file_path):
|
||||
raise AICCException("Local file already exists, please delete it first")
|
||||
|
||||
if not tos_url.startswith('http'):
|
||||
raise AICCException(f"Invalid TOS URL: {tos_url}\n URL must start with http:// or https://")
|
||||
|
||||
try:
|
||||
meta_file_npath = tos_url + "/meta.json"
|
||||
resp = request.urlopen(meta_file_npath)
|
||||
resp.raise_for_status()
|
||||
meta_json = resp.json()
|
||||
logger.info(f"Meta file content: {meta_json}")
|
||||
|
||||
file_list = meta_json.get("files", [])
|
||||
if not file_list:
|
||||
raise AICCException("No files found in the TOS directory")
|
||||
|
||||
# sort file_list by filename
|
||||
file_list.sort()
|
||||
|
||||
video_files = []
|
||||
for file_name in file_list:
|
||||
tos_file_path = tos_url + "/" + file_name
|
||||
tmp_file_path = local_file_path + "." + file_name
|
||||
if not self._download_small_file_wget(tos_file_path, tmp_file_path):
|
||||
raise AICCException(f"download file from TOS failed failed, please check the URL{tos_file_path}")
|
||||
|
||||
if encrypted_key:
|
||||
decrypt_file_path = tmp_file_path + ".dec"
|
||||
decrypt_file(encrypted_key, tmp_file_path, decrypt_file_path, mode="b")
|
||||
os.remove(tmp_file_path)
|
||||
os.rename(decrypt_file_path, tmp_file_path)
|
||||
|
||||
video_files.append(tmp_file_path)
|
||||
|
||||
# 将ret_video_file中的文件合并输出到ret_video_file_path
|
||||
with open(local_file_path, 'wb') as f:
|
||||
for video_file in video_files:
|
||||
with open(video_file, 'rb') as video:
|
||||
f.write(video.read())
|
||||
|
||||
return True
|
||||
|
||||
except AICCException as e:
|
||||
logger.error(f"Error extracting filename from TOS URL: {e}")
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Error extracting filename from TOS URL: {str(e)}")
|
||||
raise AICCException("download filename from TOS file failed, please check the URL")
|
||||
@@ -0,0 +1,45 @@
|
||||
__all__ = [
|
||||
"format_query",
|
||||
"file_to_base64",
|
||||
"base64_to_file"
|
||||
]
|
||||
|
||||
import base64
|
||||
import json
|
||||
import urllib.parse
|
||||
from typing import Dict
|
||||
|
||||
|
||||
def file_to_base64(file_path):
|
||||
with open(file_path, 'rb') as file:
|
||||
file_data = file.read() # 读取文件内容
|
||||
base64_data = base64.b64encode(file_data).decode('utf-8') # Base64 编码
|
||||
return base64_data
|
||||
|
||||
|
||||
# 辅助函数:将Base64数据写入文件
|
||||
def base64_to_file(base64_data, output_file_path):
|
||||
"""将Base64编码的数据写入文件"""
|
||||
if not base64_data:
|
||||
print("没有Base64数据可写入文件。")
|
||||
return False
|
||||
try:
|
||||
# 将Base64字符串解码为二进制数据
|
||||
binary_data = base64.b64decode(base64_data)
|
||||
# 写入文件
|
||||
with open(output_file_path, 'wb') as f:
|
||||
f.write(binary_data)
|
||||
print(f"成功将Base64数据写入文件:{output_file_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"写入文件失败:{e}")
|
||||
return False
|
||||
|
||||
|
||||
def format_query(params: Dict[str, str]) -> str:
|
||||
return "&".join(
|
||||
urllib.parse.quote(key, safe="-_.~")
|
||||
+ "="
|
||||
+ urllib.parse.quote(value, safe="-_.~")
|
||||
for key, value in params.items()
|
||||
)
|
||||
@@ -0,0 +1,231 @@
|
||||
"""
|
||||
CU开源模型机密推理客户端
|
||||
"""
|
||||
__all__ = ["CUClient"]
|
||||
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
import requests
|
||||
import bytedance.jeddak_secure_channel as jsc
|
||||
from typing import IO, Union
|
||||
from .aicc_inference import AICCInference
|
||||
from .common.constant import Constants
|
||||
from .common.error import AICCException
|
||||
from .common.log import logger
|
||||
|
||||
|
||||
class CUClient(AICCInference):
|
||||
"""
|
||||
AICC机密推理访问客户端.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
base_url: str = None,
|
||||
aicc_service_name: str = None,
|
||||
policy_id: str = None,
|
||||
is_openai: bool = True,
|
||||
is_secure: bool = True,
|
||||
ak: str = None,
|
||||
sk: str = None,
|
||||
region: str = "cn-beijing",
|
||||
**kwargs):
|
||||
if not base_url or not aicc_service_name or not policy_id or not ak or not sk:
|
||||
raise AICCException(f"New CU client failed, params error")
|
||||
|
||||
super().__init__(base_url, is_openai, is_secure, ak, sk, region)
|
||||
|
||||
self.aicc_service_name = aicc_service_name
|
||||
self.policy_id = policy_id
|
||||
self.kwargs = kwargs
|
||||
|
||||
self.jsc_client = None
|
||||
self.max_retries = 3
|
||||
|
||||
if is_secure:
|
||||
jsc_config = dict()
|
||||
jsc_config["ra_service_name"] = aicc_service_name
|
||||
jsc_config["ra_policy_id"] = policy_id
|
||||
jsc_config["bytedance_top_info"] = dict()
|
||||
jsc_config["bytedance_top_info"]["ak"] = ak
|
||||
jsc_config["bytedance_top_info"]["sk"] = sk
|
||||
jsc_config["bytedance_top_info"]["region"] = region
|
||||
jsc_config["bytedance_top_info"]["url"] = self.kwargs.get("top_url", "open.volcengineapi.com")
|
||||
jsc_config["bytedance_top_info"]["service"] = self.kwargs.get("top_service", "pcc")
|
||||
jsc_config["log_config"] = self.kwargs.get("log_config", Constants.LOG_CONFIG)
|
||||
|
||||
try:
|
||||
secure_channel_config = jsc.ClientConfig.from_dict(jsc_config)
|
||||
self.jsc_client = jsc.Client(secure_channel_config)
|
||||
except Exception as e:
|
||||
logger.error(f"Error init jsc: {e}")
|
||||
raise AICCException("create cu client failed")
|
||||
|
||||
def completions(self, prompt: str, output_stream: IO = None) -> Union[str, None]:
|
||||
"""
|
||||
生成聊天补全
|
||||
:param prompt:
|
||||
:param output_stream:
|
||||
:return:
|
||||
"""
|
||||
if not prompt:
|
||||
raise AICCException("vllm completions Prompt cannot be empty")
|
||||
|
||||
try:
|
||||
if self.is_secure:
|
||||
url = f"{self.base_url}/v2/completions"
|
||||
else:
|
||||
url = f"{self.base_url}/v1/completions"
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
payload = {
|
||||
"prompt": prompt,
|
||||
"max_tokens": int(self.kwargs.get("max_tokens", 100)),
|
||||
"temperature": float(self.kwargs.get("temperature", 1.0)),
|
||||
"stream": True if output_stream else False
|
||||
}
|
||||
|
||||
encrypt_key = None
|
||||
if self.is_secure:
|
||||
# 加密数据并发送
|
||||
try:
|
||||
encrypted_prompt, encrypt_key = self.jsc_client.encrypt_with_response(json.dumps(payload["prompt"]))
|
||||
payload["prompt"] = encrypted_prompt # 敏感字段
|
||||
except Exception as e:
|
||||
logger.error(f"Error during encrypt: {e}")
|
||||
raise AICCException("error during encrypt")
|
||||
|
||||
if output_stream:
|
||||
try:
|
||||
# 启用流式响应
|
||||
with requests.post(url, json=payload, headers=headers, stream=True) as response:
|
||||
response.raise_for_status()
|
||||
for chunk in response.iter_lines():
|
||||
if chunk:
|
||||
if self.is_secure and encrypt_key:
|
||||
data = encrypt_key.decrypt(base64.b64decode(chunk))
|
||||
output_stream.write(f"{data}")
|
||||
else:
|
||||
output_stream.write(f"{chunk}")
|
||||
except Exception as e:
|
||||
logger.error(f"call AICC VLLM service completions stream error:{e}")
|
||||
raise AICCException("vllm completions_stream error")
|
||||
else:
|
||||
for attempt in range(self.max_retries):
|
||||
try:
|
||||
response = requests.post(url, json=payload, headers=headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if self.is_secure and encrypt_key:
|
||||
for elm in data['choices']:
|
||||
elm['text'] = encrypt_key.decrypt(elm['text']).decode()
|
||||
|
||||
return json.dumps(data)
|
||||
except Exception as e:
|
||||
if attempt == self.max_retries - 1:
|
||||
logger.error(f"call AICC VLLM service completions error:{e}")
|
||||
raise AICCException("call vllm completions error")
|
||||
else:
|
||||
logger.error(f"call AICC VLLM service completions error:{e}, try again....")
|
||||
time.sleep(1)
|
||||
except AICCException as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"completions error,Exception={e}")
|
||||
raise AICCException("completions error")
|
||||
|
||||
def completions_stream(self, prompt: str, output_stream: IO) -> Union[str, None]:
|
||||
return self.completions(prompt, output_stream)
|
||||
|
||||
def chat_completions(self, messages: list, output_stream: IO = None) -> Union[str, None]:
|
||||
"""
|
||||
生成聊天补全
|
||||
:param messages:
|
||||
:param output_stream:
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
if not messages:
|
||||
raise AICCException("vllm chat_completions Messages cannot be empty")
|
||||
|
||||
if self.is_secure:
|
||||
url = f"{self.base_url}/v2/chat/completions"
|
||||
else:
|
||||
url = f"{self.base_url}/v1/chat/completions"
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
payload = {
|
||||
"messages": messages,
|
||||
"max_tokens": int(self.kwargs.get("max_tokens", 100)),
|
||||
"temperature": float(self.kwargs.get("temperature", 1.0)),
|
||||
"stream": True if output_stream else False # 启用流式响应
|
||||
}
|
||||
|
||||
encrypt_key = None
|
||||
if self.is_secure:
|
||||
# 加密数据并发送
|
||||
try:
|
||||
encrypted_messages, encrypt_key = \
|
||||
self.jsc_client.encrypt_with_response(json.dumps(payload["messages"]))
|
||||
payload["messages"] = encrypted_messages # 敏感字段
|
||||
except Exception as e:
|
||||
logger.error(f"Error during encrypt: {e}")
|
||||
raise AICCException("Error during encrypt")
|
||||
|
||||
if output_stream:
|
||||
with requests.post(url, json=payload, headers=headers, stream=True) as response:
|
||||
response.raise_for_status()
|
||||
for chunk in response.iter_lines():
|
||||
if chunk:
|
||||
if self.is_secure and encrypt_key:
|
||||
data = encrypt_key.decrypt(base64.b64decode(chunk))
|
||||
output_stream.write(f"{data}")
|
||||
else:
|
||||
output_stream.write(f"{chunk}")
|
||||
else:
|
||||
for attempt in range(self.max_retries):
|
||||
try:
|
||||
response = requests.post(url, json=payload, headers=headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if self.is_secure and encrypt_key:
|
||||
for elm in data['choices']:
|
||||
elm['message'] = json.loads(encrypt_key.decrypt(elm['message']))
|
||||
|
||||
return json.dumps(data)
|
||||
except Exception as e:
|
||||
if attempt == self.max_retries - 1:
|
||||
logger.error(f"call AICC VLLM service chat_completions error:{e}")
|
||||
raise AICCException("vllm chat_completions error")
|
||||
else:
|
||||
logger.error(f"call AICC VLLM service chat_completions error:{e}, try again....")
|
||||
time.sleep(1)
|
||||
except AICCException as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"call AICC VLLM service chat_completions error: {e}")
|
||||
raise AICCException("AICC VLLM service chat_completions error")
|
||||
|
||||
def chat_completions_stream(self, messages: list, output_stream: IO) -> Union[str, None]:
|
||||
return self.chat_completions(messages, output_stream)
|
||||
|
||||
def function_call(self, messages: list, tools: list, output_stream: IO = None):
|
||||
"""
|
||||
:param messages:
|
||||
:param tools:
|
||||
:param output_stream:
|
||||
:return:
|
||||
"""
|
||||
error_info = "Sorry, this version does not support function_call at this time."
|
||||
logger.error(error_info)
|
||||
raise AICCException(error_info)
|
||||
|
||||
def function_call_stream(self, messages: list, tools: list, output_stream: IO):
|
||||
return self.function_call(messages, tools, output_stream)
|
||||
|
||||
def asr_recognize(self, file_name: str) -> Union[str, None]:
|
||||
error_info = "Sorry, this version does not support asr at this time."
|
||||
logger.error(error_info)
|
||||
return error_info
|
||||
@@ -0,0 +1,501 @@
|
||||
"""
|
||||
DeepSeek机密推理客户端
|
||||
"""
|
||||
__all__ = ["DeepSeekClient"]
|
||||
|
||||
import base64
|
||||
import copy
|
||||
import json
|
||||
import time
|
||||
import requests
|
||||
import pickle
|
||||
import bytedance.jeddak_secure_channel as jsc
|
||||
from typing import IO, Union
|
||||
|
||||
from openai.types.chat import ChatCompletionMessage, ChatCompletionChunk
|
||||
|
||||
from .aicc_inference import AICCInference
|
||||
from .common.constant import Constants
|
||||
from .common.error import AICCException
|
||||
from .common.log import logger
|
||||
from openai import OpenAI
|
||||
|
||||
|
||||
class DeepSeekClient(AICCInference):
|
||||
"""
|
||||
DeepSeek机密推理客户端.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
base_url: str = None,
|
||||
api_key: str = None,
|
||||
ep: str = None,
|
||||
aicc_service_name: str = None,
|
||||
policy_id: str = None,
|
||||
is_openai: bool = True,
|
||||
is_secure: bool = True,
|
||||
ak: str = None,
|
||||
sk: str = None,
|
||||
region: str = "cn-beijing",
|
||||
**kwargs):
|
||||
if not base_url or not api_key or not aicc_service_name or not policy_id or not ak or not sk:
|
||||
raise AICCException("New deepseek client failed, params error")
|
||||
|
||||
super().__init__(base_url, is_openai, is_secure, ak, sk, region)
|
||||
|
||||
self.api_key = api_key
|
||||
self.ep = ep
|
||||
self.aicc_service_name = aicc_service_name
|
||||
self.policy_id = policy_id
|
||||
|
||||
self.kwargs = kwargs
|
||||
|
||||
self.dp_version = self.kwargs.get("dp_version", "3.1")
|
||||
self.jsc_client = None
|
||||
self.max_retries = 3
|
||||
|
||||
if is_secure:
|
||||
jsc_config = dict()
|
||||
jsc_config["ra_service_name"] = aicc_service_name
|
||||
jsc_config["ra_policy_id"] = policy_id
|
||||
jsc_config["bytedance_top_info"] = dict()
|
||||
jsc_config["bytedance_top_info"]["ak"] = ak
|
||||
jsc_config["bytedance_top_info"]["sk"] = sk
|
||||
jsc_config["bytedance_top_info"]["region"] = region
|
||||
jsc_config["bytedance_top_info"]["url"] = self.kwargs.get("top_url", "open.volcengineapi.com")
|
||||
jsc_config["bytedance_top_info"]["service"] = self.kwargs.get("top_service", "pcc")
|
||||
jsc_config["log_config"] = self.kwargs.get("log_config", Constants.LOG_CONFIG)
|
||||
|
||||
try:
|
||||
secure_channel_config = jsc.ClientConfig.from_dict(jsc_config)
|
||||
self.jsc_client = jsc.Client(secure_channel_config)
|
||||
except Exception as e:
|
||||
logger.error(f"Error init jsc: {e}")
|
||||
raise AICCException("New deepseek client failed")
|
||||
|
||||
def _encrypt_prompt_with_jsc(self, messages: list) -> object:
|
||||
"""
|
||||
使用端云互信SDK对prompt进行加密
|
||||
:param messages:
|
||||
:return: 返回对称解密密钥(多密钥时返回 role user 的content加密 密钥)
|
||||
"""
|
||||
if not messages or not self.jsc_client:
|
||||
logger.error(f"messages is NULL")
|
||||
return None
|
||||
|
||||
ret_encrypt_key = None
|
||||
for i in range(len(messages)):
|
||||
messages[i]["content"], encrypt_key = self.jsc_client.encrypt_with_response(
|
||||
json.dumps(messages[i].get("content")))
|
||||
if messages[i].get("role") == "user":
|
||||
ret_encrypt_key = encrypt_key
|
||||
|
||||
return ret_encrypt_key
|
||||
|
||||
def _chat_completion_message_to_str(self, chat_message: ChatCompletionMessage) -> str:
|
||||
ret = dict()
|
||||
ret["role"] = chat_message.role
|
||||
ret["content"] = chat_message.content
|
||||
ret["reasoning_content"] = chat_message.reasoning_content
|
||||
ret["tool_calls"] = chat_message.tool_calls
|
||||
ret["refusal"] = chat_message.refusal
|
||||
ret["annotations"] = chat_message.annotations
|
||||
ret["audio"] = chat_message.audio
|
||||
ret["function_call"] = chat_message.function_call
|
||||
return json.dumps(ret)
|
||||
|
||||
def _chat_completion_chunk_to_str(self, chat_completion_chunk: ChatCompletionChunk) -> str:
|
||||
ret = dict()
|
||||
ret["id"] = chat_completion_chunk.id
|
||||
ret["object"] = chat_completion_chunk.object
|
||||
ret["created"] = chat_completion_chunk.created
|
||||
ret["model"] = chat_completion_chunk.model
|
||||
|
||||
if chat_completion_chunk.usage:
|
||||
ret["usage"] = dict()
|
||||
ret["usage"]["completion_tokens"] = chat_completion_chunk.usage.completion_tokens
|
||||
ret["usage"]["prompt_tokens"] = chat_completion_chunk.usage.prompt_tokens
|
||||
ret["usage"]["total_tokens"] = chat_completion_chunk.usage.total_tokens
|
||||
ret["usage"]["completion_tokens_details"] = chat_completion_chunk.usage.completion_tokens_details
|
||||
ret["usage"]["prompt_tokens_details"] = chat_completion_chunk.usage.prompt_tokens_details
|
||||
ret["usage"]["reasoning_tokens"] = chat_completion_chunk.usage.reasoning_tokens
|
||||
else:
|
||||
ret["usage"] = None
|
||||
|
||||
ret["choices"] = list()
|
||||
|
||||
for choice in chat_completion_chunk.choices:
|
||||
choice_info = dict()
|
||||
choice_info["index"] = choice.index
|
||||
choice_info["logprobs"] = choice.logprobs
|
||||
choice_info["finish_reason"] = choice.finish_reason
|
||||
choice_info["matched_stop"] = choice.matched_stop
|
||||
choice_info["delta"] = dict()
|
||||
choice_info["delta"]["content"] = choice.delta.content
|
||||
choice_info["delta"]["function_call"] = choice.delta.function_call
|
||||
choice_info["delta"]["refusal"] = choice.delta.refusal
|
||||
choice_info["delta"]["role"] = choice.delta.role
|
||||
choice_info["delta"]["tool_calls"] = choice.delta.tool_calls
|
||||
choice_info["delta"]["reasoning_content"] = choice.delta.reasoning_content
|
||||
|
||||
ret["choices"].append(choice_info)
|
||||
|
||||
return json.dumps(ret)
|
||||
|
||||
def chat_completions(self, messages: list, output_stream: IO = None) -> Union[str, None]:
|
||||
"""
|
||||
生成聊天补全
|
||||
:param messages:
|
||||
:param output_stream:
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
if not messages:
|
||||
raise AICCException("DeepSeek chat_completions Messages cannot be empty")
|
||||
|
||||
# DeepSeek目前仅3.1支持openai的方式
|
||||
if self.dp_version < "3.1" and self.is_openai:
|
||||
error_info = f"Sorry, DeepSeek{self.dp_version} not support is_openai={self.is_openai}"
|
||||
logger.error(error_info)
|
||||
raise AICCException(error_info)
|
||||
|
||||
if self.is_secure:
|
||||
# V3.1用V4, V3.0用V2
|
||||
if self.dp_version < "3.1":
|
||||
url = f"{self.base_url}/v2/chat/completions"
|
||||
else:
|
||||
url = f"{self.base_url}/v4/chat/completions"
|
||||
else:
|
||||
url = f"{self.base_url}/v1/chat/completions"
|
||||
|
||||
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {self.api_key}"}
|
||||
payload = {
|
||||
"messages": messages,
|
||||
"max_tokens": int(self.kwargs.get("max_tokens", 100)),
|
||||
"temperature": float(self.kwargs.get("temperature", 1.0)),
|
||||
"chat_template_kwargs": {"thinking": self.kwargs.get("thinking", True)},
|
||||
"separate_reasoning": True
|
||||
}
|
||||
if output_stream:
|
||||
payload["stream"] = True
|
||||
payload["stop"] = "<|im_end|>"
|
||||
payload["eos_token"] = "<|im_end|>"
|
||||
|
||||
if self.is_openai:
|
||||
openai_messages = copy.deepcopy(messages)
|
||||
encrypt_key = None
|
||||
if self.is_secure:
|
||||
encrypt_key = self._encrypt_prompt_with_jsc(openai_messages)
|
||||
if not encrypt_key:
|
||||
raise AICCException("Error during openai encrypt")
|
||||
|
||||
url = url.rstrip("/chat/completions")
|
||||
openai_client = OpenAI(base_url=url, api_key=self.api_key)
|
||||
|
||||
resp = openai_client.chat.completions.create(
|
||||
model=self.ep if self.ep else "",
|
||||
messages=openai_messages,
|
||||
temperature=payload.get("temperature"),
|
||||
max_tokens=payload.get("max_tokens"),
|
||||
stream=True if output_stream else False,
|
||||
)
|
||||
|
||||
if output_stream:
|
||||
for chunk in resp:
|
||||
if self.is_secure and encrypt_key:
|
||||
if chunk.content:
|
||||
data = encrypt_key.decrypt(base64.b64decode(chunk.content)).decode("utf-8")
|
||||
if data.startswith("data:"):
|
||||
data = data.strip("data:").strip()
|
||||
output_stream.write(f"{data}\n")
|
||||
else:
|
||||
cont = self._chat_completion_chunk_to_str(chunk)
|
||||
output_stream.write(f"{cont}\n")
|
||||
return
|
||||
else:
|
||||
resp_msg = resp.choices[0].message
|
||||
if self.is_secure and encrypt_key:
|
||||
resp_msg = encrypt_key.decrypt(resp_msg).decode("utf-8")
|
||||
return resp_msg
|
||||
else:
|
||||
return self._chat_completion_message_to_str(resp_msg)
|
||||
|
||||
encrypt_key = None
|
||||
if self.is_secure:
|
||||
# 加密数据并发送
|
||||
try:
|
||||
if self.dp_version < "3.1":
|
||||
encrypted_messages, encrypt_key = \
|
||||
self.jsc_client.encrypt_with_response(pickle.dumps(payload["messages"]))
|
||||
else:
|
||||
encrypted_messages, encrypt_key = \
|
||||
self.jsc_client.encrypt_with_response(json.dumps(payload["messages"]))
|
||||
|
||||
payload["messages"] = encrypted_messages # 敏感字段
|
||||
except Exception as e:
|
||||
logger.error(f"Error during encrypt: {e}")
|
||||
raise AICCException("Error during encrypt")
|
||||
|
||||
if output_stream:
|
||||
try:
|
||||
with requests.post(url, json=payload, headers=headers, stream=True) as response:
|
||||
response.raise_for_status()
|
||||
for chunk in response.iter_lines():
|
||||
if chunk:
|
||||
if self.is_secure and encrypt_key:
|
||||
if self.dp_version < "3.1":
|
||||
data = encrypt_key.decrypt(base64.b64decode(chunk)).decode("utf-8")
|
||||
output_stream.write(f"{data}\n")
|
||||
else:
|
||||
chunk_str = chunk.decode("utf-8")
|
||||
if chunk_str.startswith("data:"):
|
||||
chunk_str = chunk_str.strip("data:").strip()
|
||||
if chunk_str == "[DONE]":
|
||||
output_stream.write(chunk_str + "\n")
|
||||
else:
|
||||
content = json.loads(chunk_str).get("content")
|
||||
data = encrypt_key.decrypt(base64.b64decode(content)).decode("utf-8")
|
||||
output_stream.write(f"{data}\n")
|
||||
else:
|
||||
chunk_str = chunk.decode("utf-8")
|
||||
if chunk_str.startswith("data:"):
|
||||
chunk_str = chunk_str.strip("data:").strip()
|
||||
output_stream.write(f"{chunk_str}\n")
|
||||
except Exception as e:
|
||||
logger.error(f"call AICC DeepSeek service chat_completions error:{e}")
|
||||
raise AICCException("DeepSeek chat_completions_stream error")
|
||||
else:
|
||||
for attempt in range(self.max_retries):
|
||||
try:
|
||||
response = requests.post(url, json=payload, headers=headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if self.is_secure and encrypt_key:
|
||||
for elm in data['choices']:
|
||||
if self.dp_version < "3.1":
|
||||
elm['message'] = pickle.loads(encrypt_key.decrypt(elm['message']))
|
||||
else:
|
||||
elm['message'] = json.loads(encrypt_key.decrypt(elm['message']))
|
||||
|
||||
return json.dumps(data)
|
||||
except Exception as e:
|
||||
if attempt == self.max_retries - 1:
|
||||
logger.error(f"call AICC DeepSeek service chat_completions error:{e}")
|
||||
raise AICCException("DeepSeek chat_completions error")
|
||||
else:
|
||||
logger.error(f"call AICC DeepSeek service chat_completions error:{e}, try again....")
|
||||
time.sleep(1)
|
||||
except AICCException as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"call AICC deepseek service chat_completions error: {e}")
|
||||
raise AICCException("AICC deepseek service chat_completions error")
|
||||
|
||||
def chat_completions_stream(self, messages: list, output_stream: IO) -> Union[str, None]:
|
||||
return self.chat_completions(messages, output_stream)
|
||||
|
||||
def chat_completions_stream_yield(self, messages: list):
|
||||
"""
|
||||
生成聊天补全
|
||||
:param messages:
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
if not messages:
|
||||
raise AICCException("DeepSeek chat_completions Messages cannot be empty")
|
||||
|
||||
# DeepSeek目前仅3.1支持openai的方式
|
||||
if self.dp_version < "3.1" and self.is_openai:
|
||||
error_info = f"Sorry, DeepSeek{self.dp_version} not support is_openai={self.is_openai}"
|
||||
logger.error(error_info)
|
||||
raise AICCException(error_info)
|
||||
|
||||
if self.is_secure:
|
||||
# V3.1用V4, V3.0用V2
|
||||
if self.dp_version < "3.1":
|
||||
url = f"{self.base_url}/v2/chat/completions"
|
||||
else:
|
||||
url = f"{self.base_url}/v4/chat/completions"
|
||||
else:
|
||||
url = f"{self.base_url}/v1/chat/completions"
|
||||
|
||||
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {self.api_key}"}
|
||||
payload = {
|
||||
"messages": messages,
|
||||
"max_tokens": int(self.kwargs.get("max_tokens", 100)),
|
||||
"temperature": float(self.kwargs.get("temperature", 1.0)),
|
||||
"chat_template_kwargs": {"thinking": self.kwargs.get("thinking", True)},
|
||||
"separate_reasoning": True,
|
||||
"stream": True,
|
||||
"stop": "<|im_end|>",
|
||||
"eos_token": "<|im_end|>"
|
||||
}
|
||||
|
||||
if self.is_openai:
|
||||
openai_messages = copy.deepcopy(messages)
|
||||
encrypt_key = None
|
||||
if self.is_secure:
|
||||
encrypt_key = self._encrypt_prompt_with_jsc(openai_messages)
|
||||
if not encrypt_key:
|
||||
raise AICCException("Error during openai encrypt")
|
||||
|
||||
url = url.rstrip("/chat/completions")
|
||||
openai_client = OpenAI(base_url=url, api_key=self.api_key)
|
||||
|
||||
resp = openai_client.chat.completions.create(
|
||||
model=self.ep if self.ep else "",
|
||||
messages=openai_messages,
|
||||
temperature=payload.get("temperature"),
|
||||
max_tokens=payload.get("max_tokens"),
|
||||
stream=True,
|
||||
)
|
||||
|
||||
for chunk in resp:
|
||||
if self.is_secure and encrypt_key:
|
||||
if chunk.content:
|
||||
data = encrypt_key.decrypt(base64.b64decode(chunk.content)).decode("utf-8")
|
||||
if data.startswith("data:"):
|
||||
data = data.strip("data:").strip()
|
||||
yield data
|
||||
else:
|
||||
cont = self._chat_completion_chunk_to_str(chunk)
|
||||
yield cont
|
||||
else:
|
||||
encrypt_key = None
|
||||
if self.is_secure:
|
||||
# 加密数据并发送
|
||||
try:
|
||||
if self.dp_version < "3.1":
|
||||
encrypted_messages, encrypt_key = \
|
||||
self.jsc_client.encrypt_with_response(pickle.dumps(payload["messages"]))
|
||||
else:
|
||||
encrypted_messages, encrypt_key = \
|
||||
self.jsc_client.encrypt_with_response(json.dumps(payload["messages"]))
|
||||
|
||||
payload["messages"] = encrypted_messages # 敏感字段
|
||||
except Exception as e:
|
||||
logger.error(f"Error during encrypt: {e}")
|
||||
raise AICCException("Error during encrypt")
|
||||
|
||||
try:
|
||||
with requests.post(url, json=payload, headers=headers, stream=True) as response:
|
||||
response.raise_for_status()
|
||||
for chunk in response.iter_lines():
|
||||
if chunk:
|
||||
if self.is_secure and encrypt_key:
|
||||
chunk_str = chunk.decode("utf-8")
|
||||
if chunk_str.startswith("data:"):
|
||||
chunk_str = chunk_str.strip("data:").strip()
|
||||
if chunk_str == "[DONE]":
|
||||
yield chunk_str
|
||||
else:
|
||||
if self.dp_version < "3.1":
|
||||
data = encrypt_key.decrypt(base64.b64decode(chunk_str)).decode("utf-8")
|
||||
else:
|
||||
content = json.loads(chunk_str).get("content")
|
||||
data = encrypt_key.decrypt(base64.b64decode(content)).decode("utf-8")
|
||||
yield data
|
||||
else:
|
||||
chunk_str = chunk.decode("utf-8")
|
||||
if chunk_str.startswith("data:"):
|
||||
chunk_str = chunk_str.strip("data:").strip()
|
||||
yield chunk_str
|
||||
except Exception as e:
|
||||
logger.error(f"call AICC DeepSeek service chat_completions_stream_yield error:{e}")
|
||||
raise AICCException("DeepSeek chat_completions_stream_yield error")
|
||||
except AICCException as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"call AICC deepseek service chat_completions_stream_yield error: {e}")
|
||||
raise AICCException("AICC deepseek service chat_completions_stream_yield error")
|
||||
|
||||
def function_call(self, messages: list, tools: list, output_stream: IO = None) -> str:
|
||||
"""
|
||||
:param messages:
|
||||
:param tools:
|
||||
:param output_stream:
|
||||
:return:
|
||||
"""
|
||||
if not messages or not tools:
|
||||
raise AICCException("DeepSeek function_call params cannot be empty")
|
||||
|
||||
try:
|
||||
# DeepSeek目前仅支持通过openai的方式使用function_call
|
||||
if not self.is_openai:
|
||||
error_info = f"Sorry, DeepSeek is_openai={self.is_openai} not support function_call."
|
||||
logger.error(error_info)
|
||||
raise AICCException(error_info)
|
||||
|
||||
# DeepSeek function_call目前仅支持密文访问
|
||||
if not self.is_secure:
|
||||
error_info = f"Sorry, DeepSeek secure={self.is_secure} not support function_call."
|
||||
logger.error(error_info)
|
||||
raise AICCException(error_info)
|
||||
|
||||
if self.is_secure:
|
||||
url = f"{self.base_url}/v4"
|
||||
else:
|
||||
url = f"{self.base_url}/v1"
|
||||
|
||||
openai_client = OpenAI(base_url=url, api_key=self.api_key)
|
||||
|
||||
openai_messages = copy.deepcopy(messages)
|
||||
encrypt_key = None
|
||||
if self.is_secure:
|
||||
encrypt_key = self._encrypt_prompt_with_jsc(openai_messages)
|
||||
if not encrypt_key:
|
||||
raise AICCException("Error during openai encrypt")
|
||||
|
||||
if self.is_secure:
|
||||
if self.dp_version < "3.1":
|
||||
encrypted_tools, _ = self.jsc_client.encrypt_with_response(pickle.dumps(tools))
|
||||
else:
|
||||
encrypted_tools, _ = self.jsc_client.encrypt_with_response(json.dumps(tools))
|
||||
else:
|
||||
encrypted_tools = tools
|
||||
|
||||
extra_fields = {
|
||||
"aicc_enc_tools": encrypted_tools
|
||||
}
|
||||
|
||||
completion = openai_client.chat.completions.create(
|
||||
model=self.ep,
|
||||
messages=openai_messages,
|
||||
tool_choice="required",
|
||||
extra_body=extra_fields,
|
||||
stream=True if output_stream else False,
|
||||
)
|
||||
|
||||
if output_stream:
|
||||
for chunk in completion:
|
||||
if not hasattr(chunk, 'content') or chunk.content is None:
|
||||
continue
|
||||
if chunk.content == "[DONE]":
|
||||
output_stream.write("[DONE]\n")
|
||||
if self.is_secure and encrypt_key:
|
||||
decrypted_data = encrypt_key.decrypt(base64.b64decode(chunk.content)).decode("utf-8")
|
||||
if decrypted_data.startswith("data:"):
|
||||
decrypted_data = decrypted_data.strip("data:").strip()
|
||||
output_stream.write(f"{decrypted_data}\n")
|
||||
else:
|
||||
output_stream.write(f"{chunk.content}\n")
|
||||
else:
|
||||
resp_msg = completion.choices[0].message
|
||||
if self.is_secure and encrypt_key:
|
||||
resp_msg = encrypt_key.decrypt(resp_msg).decode("utf-8")
|
||||
|
||||
return resp_msg
|
||||
except AICCException as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"call AICC deepseek service chat_completions error: {e}")
|
||||
raise AICCException("AICC deepseek service chat_completions error")
|
||||
|
||||
def function_call_stream(self, messages: list, tools: list, output_stream: IO) -> str:
|
||||
return self.function_call(messages, tools, output_stream)
|
||||
|
||||
def asr_recognize(self, file_name: str) -> str:
|
||||
error_info = "Sorry, this version does not support asr at this time."
|
||||
logger.error(error_info)
|
||||
return error_info
|
||||
@@ -0,0 +1,297 @@
|
||||
import httpx
|
||||
from typing import Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
from bytedance.jeddak_secure_channel import ResponseKey
|
||||
import bytedance.jeddak_secure_channel as jsc
|
||||
from .common.error import AICCException
|
||||
from .common.log import logger
|
||||
|
||||
|
||||
class AICCConfig:
|
||||
"""AICC 安全通信配置类"""
|
||||
|
||||
def __init__(self,
|
||||
ak: str = "",
|
||||
sk: str = "",
|
||||
aicc_llm_service_name: str = "AICC.ConfidentialInference",
|
||||
policy_id: str = "router_policy",
|
||||
region: str = "cn-beijing",
|
||||
top_url: str = "pcc.volcengineapi.com",
|
||||
top_service: str = "pcc",
|
||||
ra_url: str = "",
|
||||
auth_token: str = "",
|
||||
**kwargs):
|
||||
"""
|
||||
:param ak: 火山引擎用户的ak
|
||||
:param sk: 火山引擎用户的sk
|
||||
:param aicc_llm_service_name: aicc推理服务的服务名称
|
||||
:param policy_id: aicc推理服务做远程证明锁对应的policy
|
||||
:param region: aicc推理服务所在的区域
|
||||
:param service: aicc推理服务TOP网关的service
|
||||
:param ra_url: 非TCA接口时的RA接口地址
|
||||
:param auth_token: 非TCA接口时的鉴权凭据
|
||||
:param kwargs:
|
||||
"""
|
||||
self.ak = ak
|
||||
self.sk = sk
|
||||
self.aicc_llm_service_name = aicc_llm_service_name
|
||||
self.policy_id = policy_id
|
||||
self.region = region
|
||||
self.top_url = top_url
|
||||
self.top_service = top_service
|
||||
self.ra_url = ra_url
|
||||
self.auth_token = auth_token
|
||||
|
||||
self.kwargs = kwargs
|
||||
|
||||
self.HTTP_RA_PATH = "v1/security/token"
|
||||
if self.ra_url:
|
||||
if self.ra_url.startswith("wss://"):
|
||||
self.ra_url = self.ra_url.replace("wss", "https")
|
||||
parsed = urlparse(self.ra_url)
|
||||
base_domain = f"{parsed.scheme}://{parsed.netloc}"
|
||||
self.ra_url = f"{base_domain}/{self.HTTP_RA_PATH}"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""将配置对象转换为字典格式
|
||||
|
||||
:return: 包含所有配置项的字典
|
||||
"""
|
||||
config_dict = {
|
||||
"ak": self.ak,
|
||||
"sk": self.sk,
|
||||
"aicc_llm_service_name": self.aicc_llm_service_name,
|
||||
"policy_id": self.policy_id,
|
||||
"region": self.region,
|
||||
"top_url": self.top_url,
|
||||
"top_service": self.top_service,
|
||||
"ra_url": self.ra_url,
|
||||
"auth_token": self.auth_token
|
||||
}
|
||||
|
||||
# 添加额外的 kwargs
|
||||
if self.kwargs:
|
||||
config_dict.update(self.kwargs)
|
||||
|
||||
return config_dict
|
||||
|
||||
def get_jsc_config(self) -> dict:
|
||||
"""生成aicc端云互信配置
|
||||
"""
|
||||
jsc_config = dict()
|
||||
jsc_config["ra_url"] = self.ra_url
|
||||
jsc_config["auth_token"] = self.auth_token
|
||||
jsc_config["ra_service_name"] = self.aicc_llm_service_name
|
||||
jsc_config["ra_policy_id"] = self.policy_id
|
||||
|
||||
if self.ak and self.sk:
|
||||
jsc_config["bytedance_top_info"] = dict()
|
||||
jsc_config["bytedance_top_info"]["ak"] = self.ak
|
||||
jsc_config["bytedance_top_info"]["sk"] = self.sk
|
||||
jsc_config["bytedance_top_info"]["region"] = self.region
|
||||
jsc_config["bytedance_top_info"]["url"] = self.top_url
|
||||
jsc_config["bytedance_top_info"]["service"] = self.top_service
|
||||
|
||||
return jsc_config
|
||||
|
||||
|
||||
AICC_SDK_VERSION = "0.1.0"
|
||||
jsc_clients = dict()
|
||||
|
||||
|
||||
class DemoSyncByteStream(httpx.SyncByteStream):
|
||||
def __init__(self, raw_stream, encrypt_key: ResponseKey = None) -> None:
|
||||
self._raw_stream = raw_stream
|
||||
self.encrypt_key = encrypt_key
|
||||
self.buffer = b""
|
||||
|
||||
def __iter__(self):
|
||||
for part in self._raw_stream:
|
||||
if not isinstance(part, bytes):
|
||||
part = part.encode("utf-8")
|
||||
|
||||
for line in part.splitlines(keepends=True):
|
||||
self.buffer += line
|
||||
if self.buffer.endswith((b"\r", b"\n")):
|
||||
cipher_events = self.encrypt_key.decrypt(self.buffer.strip())
|
||||
cipher_events += b"\n" if self.buffer[-1] == 10 else b"" # 10 == "\n"
|
||||
self.buffer = b""
|
||||
yield cipher_events
|
||||
|
||||
if self.buffer:
|
||||
yield self.buffer
|
||||
|
||||
def close(self) -> None:
|
||||
if hasattr(self._raw_stream, "close"):
|
||||
self._raw_stream.close()
|
||||
|
||||
|
||||
class DemoAsyncByteStream(httpx.AsyncByteStream):
|
||||
def __init__(self, raw_stream, encrypt_key: ResponseKey = None) -> None:
|
||||
self._raw_stream = raw_stream
|
||||
self.encrypt_key = encrypt_key
|
||||
self.buffer = b""
|
||||
|
||||
async def __aiter__(self):
|
||||
async for part in self._raw_stream:
|
||||
if not isinstance(part, bytes):
|
||||
part = part.encode("utf-8")
|
||||
|
||||
for line in part.splitlines(keepends=True):
|
||||
self.buffer += line
|
||||
if self.buffer.endswith((b"\r", b"\n")):
|
||||
cipher_events = self.encrypt_key.decrypt(self.buffer.strip())
|
||||
cipher_events += b"\n" if self.buffer[-1] == 10 else b"" # 10 == "\n"
|
||||
self.buffer = b""
|
||||
yield cipher_events
|
||||
|
||||
if self.buffer:
|
||||
yield self.buffer
|
||||
|
||||
async def aclose(self) -> None:
|
||||
if hasattr(self._raw_stream, "aclose"):
|
||||
await self._raw_stream.aclose()
|
||||
elif hasattr(self._raw_stream, "close"):
|
||||
self._raw_stream.close()
|
||||
|
||||
|
||||
class SecureHttpClient(httpx.Client):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.aicc_config: Optional[AICCConfig] = None
|
||||
|
||||
if kwargs.get("aicc_config"):
|
||||
try:
|
||||
self.aicc_config = kwargs.pop("aicc_config")
|
||||
secure_channel_config = jsc.ClientConfig.from_dict(self.aicc_config.get_jsc_config())
|
||||
jsc_clients[self.aicc_config.aicc_llm_service_name] = jsc.Client(secure_channel_config)
|
||||
except Exception as e:
|
||||
logger.error(f"Error init jsc, aicc_llm_service_name={self.aicc_config.aicc_llm_service_name}, {e}")
|
||||
self.aicc_config = None
|
||||
raise AICCException("New deepseek client failed")
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def jsc_encryption(self, msg: bytes) -> Tuple[str, ResponseKey]:
|
||||
encrypt_key: Optional[ResponseKey] = None
|
||||
|
||||
if self.aicc_config:
|
||||
jsc_client = jsc_clients.get(self.aicc_config.aicc_llm_service_name)
|
||||
if jsc_client:
|
||||
msg, encrypt_key = jsc_client.encrypt_with_response(msg)
|
||||
|
||||
return msg, encrypt_key
|
||||
|
||||
def send(self, request: httpx.Request, *args, **kwargs):
|
||||
encrypt_key: Optional[ResponseKey] = None
|
||||
|
||||
if self.aicc_config:
|
||||
# 对所有请求进行加密(包括multipart请求)
|
||||
request.headers["X-AICC-Encryption-Enable"] = "true"
|
||||
request.headers["X-AICC-Encryption-SDK"] = "aicc"
|
||||
request.headers["X-AICC-Encryption-Version"] = AICC_SDK_VERSION
|
||||
|
||||
# 先读取内容(处理流式请求)
|
||||
try:
|
||||
content = request.content
|
||||
except httpx.RequestNotRead:
|
||||
content = request.read()
|
||||
|
||||
encryption_content, encrypt_key = self.jsc_encryption(content)
|
||||
if isinstance(encryption_content, str):
|
||||
encryption_content = encryption_content.encode("utf-8")
|
||||
|
||||
request.stream = httpx.ByteStream(encryption_content)
|
||||
delattr(request, "_content")
|
||||
# 不修改Content-Type,保持原始的Content-Type(如multipart/form-data)
|
||||
request.headers["Content-Length"] = str(len(encryption_content))
|
||||
request.read()
|
||||
|
||||
resp = super().send(request, *args, **kwargs)
|
||||
|
||||
if 200 <= resp.status_code < 300:
|
||||
if encrypt_key:
|
||||
if not kwargs.get("stream", False):
|
||||
content = resp.content
|
||||
decrypted_content = encrypt_key.decrypt(content)
|
||||
resp._content = decrypted_content
|
||||
else:
|
||||
resp.stream = DemoSyncByteStream(resp.stream, encrypt_key)
|
||||
else:
|
||||
if encrypt_key:
|
||||
try:
|
||||
if not kwargs.get("stream", False):
|
||||
content = resp.content
|
||||
decrypted_content = encrypt_key.decrypt(content)
|
||||
resp._content = decrypted_content
|
||||
else:
|
||||
resp.stream = DemoSyncByteStream(resp.stream, encrypt_key)
|
||||
except Exception as e:
|
||||
pass
|
||||
return resp
|
||||
|
||||
|
||||
class AsyncSecureHttpClient(httpx.AsyncClient):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.aicc_config: Optional[AICCConfig] = None
|
||||
|
||||
if kwargs.get("aicc_config"):
|
||||
try:
|
||||
self.aicc_config = kwargs.pop("aicc_config")
|
||||
secure_channel_config = jsc.ClientConfig.from_dict(self.aicc_config.get_jsc_config())
|
||||
jsc_clients[self.aicc_config.aicc_llm_service_name] = jsc.Client(secure_channel_config)
|
||||
except Exception as e:
|
||||
logger.error(f"Error init jsc, aicc_llm_service_name={self.aicc_config.aicc_llm_service_name}, {e}")
|
||||
self.aicc_config = None
|
||||
raise AICCException("New async secure client failed")
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def jsc_encryption(self, msg: bytes) -> Tuple[str, ResponseKey]:
|
||||
encrypt_key: Optional[ResponseKey] = None
|
||||
|
||||
if self.aicc_config:
|
||||
jsc_client = jsc_clients.get(self.aicc_config.aicc_llm_service_name)
|
||||
if jsc_client:
|
||||
msg, encrypt_key = jsc_client.encrypt_with_response(msg)
|
||||
|
||||
return msg, encrypt_key
|
||||
|
||||
async def send(self, request: httpx.Request, *args, **kwargs):
|
||||
encrypt_key: Optional[ResponseKey] = None
|
||||
|
||||
if self.aicc_config:
|
||||
request.headers["X-AICC-Encryption-Enable"] = "true"
|
||||
request.headers["X-AICC-Encryption-SDK"] = "aicc"
|
||||
request.headers["X-AICC-Encryption-Version"] = AICC_SDK_VERSION
|
||||
|
||||
encryption_content, encrypt_key = self.jsc_encryption(request.content)
|
||||
if isinstance(encryption_content, str):
|
||||
encryption_content = encryption_content.encode("utf-8")
|
||||
|
||||
request.stream = httpx.ByteStream(encryption_content)
|
||||
delattr(request, "_content")
|
||||
request.headers["Content-Type"] = "application/json"
|
||||
request.headers["Content-Length"] = str(len(encryption_content))
|
||||
|
||||
resp = await super().send(request, *args, **kwargs)
|
||||
|
||||
if 200 <= resp.status_code < 300:
|
||||
if encrypt_key:
|
||||
if not kwargs.get("stream", False):
|
||||
content = await resp.aread()
|
||||
decrypted_content = encrypt_key.decrypt(content)
|
||||
resp._content = decrypted_content
|
||||
else:
|
||||
resp.stream = DemoAsyncByteStream(resp.stream, encrypt_key)
|
||||
else:
|
||||
if encrypt_key:
|
||||
try:
|
||||
if not kwargs.get("stream", False):
|
||||
content = await resp.aread()
|
||||
decrypted_content = encrypt_key.decrypt(content)
|
||||
resp._content = decrypted_content
|
||||
else:
|
||||
resp.stream = DemoAsyncByteStream(resp.stream, encrypt_key)
|
||||
except Exception as e:
|
||||
pass
|
||||
return resp
|
||||
@@ -0,0 +1,500 @@
|
||||
"""
|
||||
seedance机密推理客户端
|
||||
"""
|
||||
__all__ = ["SeedanceClient"]
|
||||
|
||||
import os
|
||||
import base64
|
||||
import requests
|
||||
import traceback
|
||||
from urllib.parse import unquote
|
||||
from cryptography.hazmat.primitives import serialization as crypto_serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.backends import default_backend as crypto_default_backend
|
||||
from bytedance.jeddak_secure_channel.crypto import PrivateKey
|
||||
from .secure_http_client import SecureHttpClient
|
||||
from .common.tos_utils import TOSUtils
|
||||
from .common.error import AICCException
|
||||
from .common.log import logger
|
||||
from .secure_http_client import AICCConfig
|
||||
from .common.aes import decrypt_file
|
||||
|
||||
|
||||
class SeedanceClient:
|
||||
"""
|
||||
DeepSeek机密推理客户端.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
base_url: str = "",
|
||||
api_key: str = "",
|
||||
endpoint: str = "",
|
||||
aicc_config: AICCConfig = None,
|
||||
**kwargs):
|
||||
self.base_url = base_url
|
||||
self.api_key = api_key
|
||||
self.endpoint = endpoint
|
||||
self.aicc_config = aicc_config
|
||||
self.tos_utils = TOSUtils()
|
||||
self.kwargs = kwargs
|
||||
self.is_secure = self.kwargs.get("is_secure", True)
|
||||
|
||||
if aicc_config:
|
||||
self.secure_http_client = SecureHttpClient(aicc_config=aicc_config)
|
||||
else:
|
||||
if self.is_secure:
|
||||
aicc_config = AICCConfig(ra_url=base_url, auth_token=api_key)
|
||||
self.secure_http_client = SecureHttpClient(aicc_config=aicc_config)
|
||||
else:
|
||||
self.secure_http_client = SecureHttpClient()
|
||||
|
||||
# 是否开启视频文件加密, 默认加密
|
||||
self.is_enable_video_encrypt = str(self.kwargs.get("is_enable_video_encrypt", True)).lower() == "true"
|
||||
|
||||
# 视频文件的存储位置, 目前仅支持火山TOS
|
||||
self.video_file_storage_location = self.kwargs.get("video_file_storage_location", "TOS").upper()
|
||||
|
||||
self.video_file_encrypt_pub_key_content = None
|
||||
self.video_file_encrypt_priv_key_content = None
|
||||
|
||||
self.video_file_encrypt_priv_key: PrivateKey = None
|
||||
|
||||
self.timeout = self.kwargs.get("timeout", 120.0)
|
||||
|
||||
# API Endpoints
|
||||
self.CREATE_TASK_URL = "/contents/generations/tasks"
|
||||
self.QUERY_TASK_URL = "/contents/generations/tasks"
|
||||
self.QUERY_TASK_LIST_URL = "/contents/generations/tasks"
|
||||
self.DELETE_TASK_URL = "/contents/generations/tasks"
|
||||
|
||||
def _get_base_url(self):
|
||||
"""
|
||||
Get base URL with trailing slash removed
|
||||
"""
|
||||
return self.base_url.rstrip("/")
|
||||
|
||||
def set_video_file_encrypt_key(self, pub_key_path: str = "", priv_key_path: str = ""):
|
||||
"""
|
||||
Set video file encrypt key
|
||||
Args:
|
||||
pub_key_path: Public key for encrypt
|
||||
priv_key_path: Private key for decrypt
|
||||
"""
|
||||
if os.path.exists(pub_key_path):
|
||||
with open(pub_key_path, 'r') as f:
|
||||
content = f.read()
|
||||
if '-----BEGIN' in content and '-----END' in content:
|
||||
self.video_file_encrypt_pub_key_content = content
|
||||
|
||||
if os.path.exists(priv_key_path):
|
||||
with open(priv_key_path, 'r') as f:
|
||||
content = f.read()
|
||||
if '-----BEGIN' in content and '-----END' in content:
|
||||
self.video_file_encrypt_priv_key_content = content
|
||||
self.video_file_encrypt_priv_key = \
|
||||
PrivateKey.from_private_pem(self.video_file_encrypt_priv_key_content)
|
||||
|
||||
def _generate_key_pair(self, pub_key_path: str = "", priv_key_path: str = ""):
|
||||
"""
|
||||
Generate RSA 4096 key pair and save to files
|
||||
|
||||
Args:
|
||||
pub_key_path: Path to save public key
|
||||
priv_key_path: Path to save private key
|
||||
|
||||
Returns:
|
||||
Tuple of (public_key_path, private_key_path)
|
||||
"""
|
||||
try:
|
||||
# Generate RSA key pair
|
||||
key = rsa.generate_private_key(
|
||||
backend=crypto_default_backend(),
|
||||
public_exponent=65537,
|
||||
key_size=4096
|
||||
)
|
||||
|
||||
# Save private key
|
||||
private_key = key.private_bytes(
|
||||
crypto_serialization.Encoding.PEM,
|
||||
crypto_serialization.PrivateFormat.PKCS8,
|
||||
crypto_serialization.NoEncryption()
|
||||
)
|
||||
with open(priv_key_path, 'wb') as f:
|
||||
f.write(private_key)
|
||||
|
||||
# Save public key
|
||||
public_key = key.public_key().public_bytes(
|
||||
crypto_serialization.Encoding.PEM,
|
||||
crypto_serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
)
|
||||
with open(pub_key_path, 'wb') as f:
|
||||
f.write(public_key)
|
||||
except Exception as e:
|
||||
logger.error(f"[Error] Failed to generate key pair: {e}")
|
||||
raise AICCException("Failed to generate RSA key pair") from e
|
||||
|
||||
def generate_video_file_encrypt_key(self, pub_key_path: str = "", priv_key_path: str = ""):
|
||||
"""
|
||||
Generate video file encrypt key
|
||||
Args:
|
||||
pub_key_path: Public key for encrypt
|
||||
priv_key_path: Private key for decrypt
|
||||
"""
|
||||
# 如果没有提供路径报错
|
||||
if not pub_key_path or not priv_key_path:
|
||||
raise ValueError("pub_key_path and priv_key_path must be provided")
|
||||
|
||||
self._generate_key_pair(pub_key_path, priv_key_path)
|
||||
# self.set_video_file_encrypt_key(pub_key_path, priv_key_path)
|
||||
|
||||
def create_video_generation_task(self, data: dict, custom_header: dict = None) -> str:
|
||||
"""
|
||||
:param data:
|
||||
:param custom_header:
|
||||
:return:
|
||||
"""
|
||||
if not data:
|
||||
raise AICCException("data must be provided")
|
||||
|
||||
if "model" not in data:
|
||||
data["model"] = self.endpoint
|
||||
|
||||
base_url = self._get_base_url()
|
||||
url = f"{base_url}{self.CREATE_TASK_URL}"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.api_key}"
|
||||
}
|
||||
if custom_header:
|
||||
headers.update(custom_header)
|
||||
|
||||
# Add encryption headers if provided
|
||||
if self.is_enable_video_encrypt:
|
||||
if not self.video_file_encrypt_pub_key_content or not self.video_file_encrypt_priv_key_content:
|
||||
raise AICCException("pub_key and priv_key must be provided when Is-Enable-Encrypt is 'true'")
|
||||
|
||||
headers["Enable-TOS-Content-Result-Encryption"] = str(self.is_enable_video_encrypt).lower()
|
||||
headers["X-Encryption-Algorithm"] = "RSA_OAEP_4096_AES_256"
|
||||
if self.video_file_encrypt_pub_key_content:
|
||||
# Base64 encode the PEM public key for safe HTTP header transmission
|
||||
# This avoids issues with newlines and special characters in headers
|
||||
pk_base64 = base64.b64encode(self.video_file_encrypt_pub_key_content.encode()).decode()
|
||||
headers["PK"] = pk_base64
|
||||
|
||||
# logger.info(f"[Request] Data: {json.dumps(data, ensure_ascii=False, indent=2)}")
|
||||
|
||||
response = self.secure_http_client.post(url, headers=headers, json=data, timeout=self.timeout)
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
task_id = result.get("id")
|
||||
return task_id
|
||||
else:
|
||||
logger.error(f"[Error] url: {url}, response: {response.text}")
|
||||
return ""
|
||||
|
||||
def create_video_generation_task_by_message(self, message: list, custom_header: dict = None,
|
||||
generate_audio: bool = True,
|
||||
ratio: str = "16:9", duration: int = 11,
|
||||
watermark: bool = False) -> str:
|
||||
"""
|
||||
message: List of messages to generate video
|
||||
"""
|
||||
if not message:
|
||||
raise AICCException("message must be provided")
|
||||
|
||||
data = {
|
||||
"model": self.endpoint,
|
||||
"content": message,
|
||||
"generate_audio": generate_audio,
|
||||
"ratio": ratio,
|
||||
"duration": duration,
|
||||
"watermark": watermark
|
||||
}
|
||||
return self.create_video_generation_task(data, custom_header)
|
||||
|
||||
def query_video_generation_task(self, task_id: str) -> dict:
|
||||
"""
|
||||
Query video generation task
|
||||
API: GET /api/v3/contents/generations/tasks/{task_id}
|
||||
"""
|
||||
|
||||
if not task_id:
|
||||
raise AICCException("task_id must be provided")
|
||||
|
||||
base_url = self._get_base_url()
|
||||
url = f"{base_url}{self.QUERY_TASK_URL}/{task_id}"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.api_key}"
|
||||
}
|
||||
|
||||
# logger.info(f"[Request] URL: {url}")
|
||||
|
||||
# response = self.secure_http_client.get(url, headers=headers, timeout=self.timeout)
|
||||
response = self.secure_http_client.request(
|
||||
"GET", url, headers=headers, content=b"{}", timeout=self.timeout
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
# logger.info(f"[Response] Body: {json.dumps(result, ensure_ascii=False, indent=2)}")
|
||||
|
||||
# status = result.get("status")
|
||||
# if status == "succeeded":
|
||||
# video_url = result.get("content", {}).get("video_url")
|
||||
# print(f"[Video URL] {video_url}")
|
||||
|
||||
return result
|
||||
else:
|
||||
try:
|
||||
result = response.json()
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"[Error] url: {url}, Exception={e}")
|
||||
raise AICCException(f"Failed to query video generation task")
|
||||
|
||||
def query_video_generation_task_list(
|
||||
self,
|
||||
page_num: int = 0,
|
||||
page_size: int = 0,
|
||||
status: str = "",
|
||||
task_ids: list = None,
|
||||
model: str = "",
|
||||
service_tier: str = ""
|
||||
) -> dict:
|
||||
"""
|
||||
Query video generation task list
|
||||
API: GET /api/v3/contents/generations/tasks
|
||||
|
||||
Args:
|
||||
page_num: Page number (only added if > 0)
|
||||
page_size: Page size (only added if > 0)
|
||||
status: Filter by status (only added if not empty)
|
||||
task_ids: Filter by task IDs (only added if not empty list)
|
||||
model: Filter by model (only added if not empty)
|
||||
service_tier: Filter by service tier (only added if not empty)
|
||||
|
||||
Returns:
|
||||
Query result dict
|
||||
"""
|
||||
from urllib.parse import urlencode
|
||||
|
||||
base_url = self._get_base_url()
|
||||
|
||||
# Build query parameters (only add non-empty values)
|
||||
params = []
|
||||
|
||||
if page_num > 0:
|
||||
params.append(f"page_num={page_num}")
|
||||
|
||||
if page_size > 0:
|
||||
params.append(f"page_size={page_size}")
|
||||
|
||||
if status:
|
||||
params.append(f"filter.status={status}")
|
||||
|
||||
if task_ids and len(task_ids) > 0:
|
||||
for task_id in task_ids:
|
||||
params.append(f"filter.task_ids={task_id}")
|
||||
|
||||
if model:
|
||||
params.append(f"filter.model={model}")
|
||||
|
||||
if service_tier:
|
||||
params.append(f"filter.service_tier={service_tier}")
|
||||
|
||||
# Build URL with query parameters
|
||||
query_string = "&".join(params)
|
||||
if query_string:
|
||||
url = f"{base_url}{self.QUERY_TASK_LIST_URL}?{query_string}"
|
||||
else:
|
||||
url = f"{base_url}{self.QUERY_TASK_LIST_URL}"
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.api_key}"
|
||||
}
|
||||
|
||||
response = self.secure_http_client.get(url, headers=headers, timeout=self.timeout)
|
||||
try:
|
||||
result = response.json()
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"query_video_generation_task_list error: {e}")
|
||||
raise AICCException(f"Failed to query video generation task list")
|
||||
|
||||
def delete_video_generation_task(self, task_id: str):
|
||||
"""
|
||||
Delete video generation task
|
||||
API: DELETE /api/v3/contents/generations/tasks/{task_id}
|
||||
"""
|
||||
if not task_id:
|
||||
raise AICCException("task_id must be provided")
|
||||
|
||||
base_url = self._get_base_url()
|
||||
url = f"{base_url}{self.DELETE_TASK_URL}/{task_id}"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.api_key}"
|
||||
}
|
||||
response = self.secure_http_client.delete(url, headers=headers, timeout=self.timeout)
|
||||
try:
|
||||
result = response.json()
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"delete_video_generation_task error: {e}")
|
||||
raise AICCException(f"Failed to delete_video_generation_task")
|
||||
|
||||
def video_file_download(self, task_id: str, video_file_path: str) -> bool:
|
||||
"""
|
||||
Download and decrypt encrypted video
|
||||
This function polls the task status until it succeeds, then downloads
|
||||
and decrypts the video using the provided private key.
|
||||
Args:
|
||||
task_id: Video generation task ID
|
||||
video_file_path: Directory to save decrypted video
|
||||
|
||||
Returns:
|
||||
Path to decrypted video file, or None if failed
|
||||
"""
|
||||
if not task_id:
|
||||
raise AICCException("task_id must be provided")
|
||||
|
||||
if not video_file_path:
|
||||
raise AICCException("video_file_path must be provided")
|
||||
|
||||
result = self.query_video_generation_task(task_id)
|
||||
if not result:
|
||||
logger.error(f"[Error] Failed to query task,task_id={task_id}")
|
||||
return False
|
||||
|
||||
status = result.get("status")
|
||||
if status == "succeeded":
|
||||
video_url = result.get("content", {}).get("video_url")
|
||||
if not video_url:
|
||||
logger.error("[Error] No video_url in response")
|
||||
return False
|
||||
|
||||
return self.download_file_normal(video_url, video_file_path)
|
||||
# encrypted_key = b""
|
||||
# if self.is_enable_video_encrypt:
|
||||
# encrypted_key = result.get("encrypted_key")
|
||||
# if not encrypted_key:
|
||||
# logger.error("[Error] No encrypted_key in response")
|
||||
# return False
|
||||
# if not self.video_file_encrypt_priv_key:
|
||||
# logger.error("[Error] No private key set, cannot decrypt encrypted video")
|
||||
# return False
|
||||
# else:
|
||||
# # Base64 decode the encrypted key before RSA decryption
|
||||
# encrypted_key_bytes = base64.b64decode(encrypted_key)
|
||||
# encrypted_key = self.video_file_encrypt_priv_key.decrypt(encrypted_key_bytes)
|
||||
#
|
||||
# logger.info(f"\n[Downloading] Starting download and decryption...")
|
||||
#
|
||||
# try:
|
||||
# if self.video_file_storage_location == "TOS":
|
||||
# self.tos_utils.download_file(tos_url=video_url, local_file_path=video_file_path,
|
||||
# encrypted_key=encrypted_key)
|
||||
# else:
|
||||
# logger.error(f"[Error] Unsupported video storage location: {self.video_file_storage_location}")
|
||||
# return False
|
||||
#
|
||||
# logger.info(f"\n[Success] Decrypted video saved to: {video_file_path}")
|
||||
# return True
|
||||
#
|
||||
# except Exception as e:
|
||||
# logger.error(f"[Error] Download and decryption failed: {str(e)}")
|
||||
# return False
|
||||
|
||||
elif status == "failed":
|
||||
error_info = result.get("error", "Unknown error")
|
||||
logger.error(f"[Error] Task failed: {error_info}")
|
||||
return False
|
||||
|
||||
elif status in ["pending", "running", "queued"]:
|
||||
logger.info(f"[Waiting] Task is {status}, please wait for it to finish...")
|
||||
return False
|
||||
else:
|
||||
logger.warning(f"[Warning] Unknown status: {status}")
|
||||
return False
|
||||
|
||||
def video_file_download_by_url(self, video_url: str, video_file_path: str) -> bool:
|
||||
"""
|
||||
Download and decrypt encrypted video
|
||||
This function polls the task status until it succeeds, then downloads
|
||||
and decrypts the video using the provided private key.
|
||||
Args:
|
||||
video_url: Video video_url
|
||||
video_file_path: Directory to save decrypted video
|
||||
|
||||
Returns:
|
||||
Path to decrypted video file, or None if failed
|
||||
"""
|
||||
if not video_url:
|
||||
raise AICCException("video_url must be provided")
|
||||
|
||||
if not video_file_path:
|
||||
raise AICCException("video_file_path must be provided")
|
||||
|
||||
logger.info(f"\n[Downloading] Starting download and decryption...")
|
||||
|
||||
try:
|
||||
return self.download_file_normal(video_url, video_file_path)
|
||||
except Exception as e:
|
||||
logger.error(f"[Error] Download and decryption failed: {str(e)}")
|
||||
return False
|
||||
|
||||
def download_file_normal(self, video_url: str, video_file_path: str) -> bool:
|
||||
"""
|
||||
Download video file
|
||||
Args:
|
||||
video_url: Video video_url
|
||||
video_file_path: Directory to save video file
|
||||
|
||||
Returns:
|
||||
Path to video file, or None if failed
|
||||
"""
|
||||
if not video_url:
|
||||
raise AICCException("video_url must be provided")
|
||||
|
||||
if not video_file_path:
|
||||
raise AICCException("video_file_path must be provided")
|
||||
|
||||
if os.path.exists(video_file_path) and os.path.getsize(video_file_path) > 0:
|
||||
logger.error(f"[Error] File {video_file_path} already exists and is not empty")
|
||||
return False
|
||||
|
||||
try:
|
||||
response = requests.get(video_url, stream=True)
|
||||
with open(video_file_path, "wb") as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
|
||||
if self.is_enable_video_encrypt:
|
||||
if not self.video_file_encrypt_priv_key:
|
||||
logger.error("[Error] No private key set, cannot decrypt encrypted video")
|
||||
return False
|
||||
|
||||
headers = response.headers
|
||||
encrypted_key = headers.get("x-tos-meta-enc-dek")
|
||||
# nonce = headers.get("x-tos-meta-x-tos-meta-base-nonce")
|
||||
if not encrypted_key:
|
||||
logger.error("[Error] No encrypted_key in response")
|
||||
return False
|
||||
|
||||
try:
|
||||
encrypted_key_bytes = base64.b64decode(unquote(encrypted_key))
|
||||
encrypted_key = self.video_file_encrypt_priv_key.decrypt(encrypted_key_bytes)
|
||||
tmp_file_path = video_file_path + ".tmp"
|
||||
decrypt_file(encrypted_key, video_file_path, tmp_file_path, 'b')
|
||||
os.remove(video_file_path)
|
||||
os.rename(tmp_file_path, video_file_path)
|
||||
except Exception as e:
|
||||
logger.error(f"[Error] Failed to decode encrypted_key: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"[Error] Download failed: {e}")
|
||||
traceback.print_exc()
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
seedream机密推理客户端
|
||||
"""
|
||||
__all__ = ["SeedreamClient"]
|
||||
|
||||
import os
|
||||
import uuid
|
||||
import base64
|
||||
import traceback
|
||||
from urllib.parse import unquote
|
||||
from cryptography.hazmat.primitives import serialization as crypto_serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.backends import default_backend as crypto_default_backend
|
||||
from bytedance.jeddak_secure_channel.crypto import PrivateKey
|
||||
from .secure_http_client import SecureHttpClient
|
||||
from .common.error import AICCException
|
||||
from .common.log import logger
|
||||
from .secure_http_client import AICCConfig
|
||||
from .common.aes import decrypt_file
|
||||
|
||||
|
||||
class SeedreamClient:
|
||||
|
||||
def __init__(self,
|
||||
base_url: str = "",
|
||||
api_key: str = "",
|
||||
endpoint: str = "",
|
||||
aicc_config: AICCConfig = None,
|
||||
**kwargs):
|
||||
self.base_url = base_url
|
||||
self.api_key = api_key
|
||||
self.endpoint = endpoint
|
||||
self.aicc_config = aicc_config
|
||||
self.kwargs = kwargs
|
||||
self.is_secure = self.kwargs.get("is_secure", True)
|
||||
|
||||
if aicc_config:
|
||||
self.secure_http_client = SecureHttpClient(aicc_config=aicc_config)
|
||||
else:
|
||||
if self.is_secure:
|
||||
aicc_config = AICCConfig(ra_url=base_url, auth_token=api_key)
|
||||
self.secure_http_client = SecureHttpClient(aicc_config=aicc_config)
|
||||
else:
|
||||
self.secure_http_client = SecureHttpClient()
|
||||
|
||||
self.is_enable_image_encrypt = str(self.kwargs.get("is_enable_image_encrypt", True)).lower() == "true"
|
||||
|
||||
self.image_file_encrypt_pub_key_content = None
|
||||
self.image_file_encrypt_priv_key_content = None
|
||||
|
||||
self.image_file_encrypt_priv_key: PrivateKey = None
|
||||
|
||||
self.timeout = self.kwargs.get("timeout", 120.0)
|
||||
|
||||
self.CREATE_IMAGE_URL = "/images/generations"
|
||||
|
||||
def _get_base_url(self):
|
||||
return self.base_url.rstrip("/")
|
||||
|
||||
def _build_common_headers(self, request_id: str = "") -> dict[str, str]:
|
||||
if not request_id:
|
||||
request_id = str(uuid.uuid4())
|
||||
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"X-Api-App-Key": self.endpoint,
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"X-Request-Id": request_id,
|
||||
}
|
||||
|
||||
def generate_key_pair(self, pub_key_path: str = "", priv_key_path: str = ""):
|
||||
try:
|
||||
key = rsa.generate_private_key(
|
||||
backend=crypto_default_backend(),
|
||||
public_exponent=65537,
|
||||
key_size=4096
|
||||
)
|
||||
|
||||
private_key = key.private_bytes(
|
||||
crypto_serialization.Encoding.PEM,
|
||||
crypto_serialization.PrivateFormat.PKCS8,
|
||||
crypto_serialization.NoEncryption()
|
||||
)
|
||||
with open(priv_key_path, 'wb') as f:
|
||||
f.write(private_key)
|
||||
|
||||
public_key = key.public_key().public_bytes(
|
||||
crypto_serialization.Encoding.PEM,
|
||||
crypto_serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
)
|
||||
with open(pub_key_path, 'wb') as f:
|
||||
f.write(public_key)
|
||||
except Exception as e:
|
||||
logger.error(f"[Error] Failed to generate key pair: {e}")
|
||||
raise AICCException("Failed to generate RSA key pair") from e
|
||||
|
||||
def set_image_encrypt_key(self, pub_key_path: str = "", priv_key_path: str = ""):
|
||||
if os.path.exists(pub_key_path):
|
||||
with open(pub_key_path, 'r') as f:
|
||||
content = f.read()
|
||||
if '-----BEGIN' in content and '-----END' in content:
|
||||
self.image_file_encrypt_pub_key_content = content
|
||||
|
||||
if os.path.exists(priv_key_path):
|
||||
with open(priv_key_path, 'r') as f:
|
||||
content = f.read()
|
||||
if '-----BEGIN' in content and '-----END' in content:
|
||||
self.image_file_encrypt_priv_key_content = content
|
||||
self.image_file_encrypt_priv_key = \
|
||||
PrivateKey.from_private_pem(self.image_file_encrypt_priv_key_content)
|
||||
|
||||
def create_image(self, data: dict, custom_header: dict = None, request_id: str = ""):
|
||||
if not data:
|
||||
raise AICCException("data must be provided")
|
||||
|
||||
if "model" not in data:
|
||||
data["model"] = self.endpoint
|
||||
|
||||
base_url = self._get_base_url()
|
||||
url = f"{base_url}{self.CREATE_IMAGE_URL}"
|
||||
|
||||
headers = self._build_common_headers(request_id)
|
||||
if custom_header:
|
||||
headers.update(custom_header)
|
||||
|
||||
if self.is_enable_image_encrypt:
|
||||
if not self.image_file_encrypt_pub_key_content or not self.image_file_encrypt_priv_key_content:
|
||||
raise AICCException("pub_key and priv_key must be provided when Is-Enable-Encrypt is 'true'")
|
||||
|
||||
headers["Enable-TOS-Content-Result-Encryption"] = str(self.is_enable_image_encrypt).lower()
|
||||
headers["X-Encryption-Algorithm"] = "RSA_OAEP_4096_AES_256"
|
||||
if self.image_file_encrypt_pub_key_content:
|
||||
pk_base64 = base64.b64encode(self.image_file_encrypt_pub_key_content.encode()).decode()
|
||||
headers["PK"] = pk_base64
|
||||
|
||||
stream = data.get("stream", False)
|
||||
if stream:
|
||||
return self.secure_http_client.send(
|
||||
self.secure_http_client.build_request("POST", url, headers=headers, json=data, timeout=self.timeout),
|
||||
stream=True,
|
||||
)
|
||||
|
||||
response = self.secure_http_client.post(url, headers=headers, json=data, timeout=self.timeout)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
logger.error(f"[Error] url: {url}, response: {response.text}")
|
||||
try:
|
||||
return response.json()
|
||||
except Exception:
|
||||
raise AICCException(f"Failed to create image, status_code={response.status_code}")
|
||||
|
||||
def create_image_by_prompt(self, prompt: str, custom_header: dict = None, request_id: str = "",
|
||||
size: str = "1024x1024", n: int = 1, response_format: str = "url",
|
||||
stream: bool = False, extra_body: dict = None):
|
||||
if not prompt:
|
||||
raise AICCException("prompt must be provided")
|
||||
|
||||
data = {
|
||||
"model": self.endpoint,
|
||||
"prompt": prompt,
|
||||
"size": size,
|
||||
"n": n,
|
||||
"response_format": response_format,
|
||||
}
|
||||
if stream:
|
||||
data["stream"] = True
|
||||
if extra_body:
|
||||
data.update(extra_body)
|
||||
return self.create_image(data, custom_header, request_id)
|
||||
|
||||
def download_image(self, image_url: str, local_file_path: str) -> bool:
|
||||
if not image_url:
|
||||
raise AICCException("image_url must be provided")
|
||||
|
||||
if not local_file_path:
|
||||
raise AICCException("local_file_path must be provided")
|
||||
|
||||
if os.path.exists(local_file_path) and os.path.getsize(local_file_path) > 0:
|
||||
logger.error(f"[Error] File {local_file_path} already exists and is not empty")
|
||||
return False
|
||||
|
||||
try:
|
||||
import requests as req
|
||||
response = req.get(image_url, stream=True)
|
||||
with open(local_file_path, "wb") as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
|
||||
if self.is_enable_image_encrypt:
|
||||
if not self.image_file_encrypt_priv_key:
|
||||
logger.error("[Error] No private key set, cannot decrypt encrypted image")
|
||||
return False
|
||||
|
||||
headers = response.headers
|
||||
encrypted_key = headers.get("x-tos-meta-enc-dek")
|
||||
if encrypted_key:
|
||||
try:
|
||||
encrypted_key_bytes = base64.b64decode(unquote(encrypted_key))
|
||||
encrypted_key = self.image_file_encrypt_priv_key.decrypt(encrypted_key_bytes)
|
||||
tmp_file_path = local_file_path + ".tmp"
|
||||
decrypt_file(encrypted_key, local_file_path, tmp_file_path, 'b')
|
||||
os.remove(local_file_path)
|
||||
os.rename(tmp_file_path, local_file_path)
|
||||
except Exception as e:
|
||||
logger.error(f"[Error] Failed to decode encrypted_key: {e}")
|
||||
traceback.print_exc()
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"[Error] Download failed: {e}")
|
||||
traceback.print_exc()
|
||||
return False
|
||||
return True
|
||||
Reference in New Issue
Block a user