fix(settings): reconcile secret status and model drafts
This commit is contained in:
@@ -26,7 +26,7 @@ def get_or_create_secret_key() -> bytes:
|
||||
if SECRET_KEY_FILE.exists():
|
||||
encoded = SECRET_KEY_FILE.read_text(encoding="utf-8").strip()
|
||||
if encoded:
|
||||
return base64.urlsafe_b64decode(encoded.encode("ascii"))
|
||||
return read_secret_key()
|
||||
|
||||
secret_key = secrets.token_bytes(KEY_BYTES)
|
||||
encoded = base64.urlsafe_b64encode(secret_key).decode("ascii")
|
||||
@@ -34,6 +34,23 @@ def get_or_create_secret_key() -> bytes:
|
||||
return secret_key
|
||||
|
||||
|
||||
def read_secret_key() -> bytes:
|
||||
"""只读取现有主密钥,解密路径不得因缺失密钥而生成新文件。"""
|
||||
|
||||
if not SECRET_KEY_FILE.exists():
|
||||
raise ValueError("Secret key file is missing")
|
||||
|
||||
try:
|
||||
encoded = SECRET_KEY_FILE.read_text(encoding="utf-8").strip()
|
||||
secret_key = base64.urlsafe_b64decode(encoded.encode("ascii"))
|
||||
except (OSError, UnicodeError, ValueError) as exc:
|
||||
raise ValueError("Secret key file is invalid") from exc
|
||||
|
||||
if len(secret_key) != KEY_BYTES:
|
||||
raise ValueError("Secret key length is invalid")
|
||||
return secret_key
|
||||
|
||||
|
||||
def _keystream(secret_key: bytes, nonce: bytes, length: int) -> bytes:
|
||||
chunks: list[bytes] = []
|
||||
counter = 0
|
||||
@@ -57,7 +74,14 @@ def encrypt_secret(value: str) -> str:
|
||||
secret_key = get_or_create_secret_key()
|
||||
nonce = secrets.token_bytes(NONCE_BYTES)
|
||||
plaintext = value.encode("utf-8")
|
||||
ciphertext = bytes(a ^ b for a, b in zip(plaintext, _keystream(secret_key, nonce, len(plaintext)), strict=False))
|
||||
ciphertext = bytes(
|
||||
a ^ b
|
||||
for a, b in zip(
|
||||
plaintext,
|
||||
_keystream(secret_key, nonce, len(plaintext)),
|
||||
strict=False,
|
||||
)
|
||||
)
|
||||
mac = hmac.new(secret_key, b"mac:" + nonce + ciphertext, hashlib.sha256).digest()
|
||||
|
||||
encoded_nonce = base64.urlsafe_b64encode(nonce).decode("ascii")
|
||||
@@ -78,7 +102,7 @@ def decrypt_secret(value: str) -> str:
|
||||
if version != SECRET_BOX_VERSION:
|
||||
raise ValueError("Unsupported secret payload version")
|
||||
|
||||
secret_key = get_or_create_secret_key()
|
||||
secret_key = read_secret_key()
|
||||
nonce = base64.urlsafe_b64decode(encoded_nonce.encode("ascii"))
|
||||
ciphertext = base64.urlsafe_b64decode(encoded_ciphertext.encode("ascii"))
|
||||
expected_mac = base64.urlsafe_b64decode(encoded_mac.encode("ascii"))
|
||||
@@ -87,5 +111,12 @@ def decrypt_secret(value: str) -> str:
|
||||
if not hmac.compare_digest(actual_mac, expected_mac):
|
||||
raise ValueError("Secret payload integrity check failed")
|
||||
|
||||
plaintext = bytes(a ^ b for a, b in zip(ciphertext, _keystream(secret_key, nonce, len(ciphertext)), strict=False))
|
||||
plaintext = bytes(
|
||||
a ^ b
|
||||
for a, b in zip(
|
||||
ciphertext,
|
||||
_keystream(secret_key, nonce, len(ciphertext)),
|
||||
strict=False,
|
||||
)
|
||||
)
|
||||
return plaintext.decode("utf-8")
|
||||
|
||||
@@ -126,7 +126,11 @@ class OnlyOfficeRuntimeConfig:
|
||||
jwt_secret: str
|
||||
|
||||
|
||||
def serialize_model_rows(model_rows: dict[str, SystemModelSetting]) -> list[dict[str, object]]:
|
||||
def serialize_model_rows(
|
||||
model_rows: dict[str, SystemModelSetting],
|
||||
*,
|
||||
configured_slots: set[str],
|
||||
) -> list[dict[str, object]]:
|
||||
ordered_rows = sorted(
|
||||
model_rows.values(),
|
||||
key=lambda row: (int(row.priority or 0), str(row.slot or "")),
|
||||
@@ -138,7 +142,7 @@ def serialize_model_rows(model_rows: dict[str, SystemModelSetting]) -> list[dict
|
||||
"provider": row.provider,
|
||||
"url": row.endpoint,
|
||||
"apiKey": "",
|
||||
"apiKeyConfigured": bool(row.api_key_encrypted),
|
||||
"apiKeyConfigured": row.slot in configured_slots,
|
||||
"modelId": row.model_name,
|
||||
"type": MODEL_CAPABILITY_TO_TYPE.get(str(row.capability or "chat"), "llm"),
|
||||
}
|
||||
@@ -662,13 +666,20 @@ class SettingsService:
|
||||
)
|
||||
|
||||
def _decrypt_model_api_key(self, encrypted_value: str, *, slot: str) -> str:
|
||||
return self._decrypt_saved_secret(
|
||||
encrypted_value,
|
||||
label=f"model API key for slot={slot}",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _decrypt_saved_secret(encrypted_value: str, *, label: str) -> str:
|
||||
normalized_value = str(encrypted_value or "").strip()
|
||||
if not normalized_value:
|
||||
return ""
|
||||
try:
|
||||
return decrypt_secret(normalized_value)
|
||||
except ValueError:
|
||||
logger.warning("Skipping undecryptable model API key for slot=%s", slot)
|
||||
logger.warning("Skipping undecryptable %s", label)
|
||||
return ""
|
||||
|
||||
def _ensure_settings_schema(self) -> None:
|
||||
@@ -842,8 +853,8 @@ class SettingsService:
|
||||
else:
|
||||
config.cron_expression = f"{int(parts[1])} {int(parts[0])} * * *"
|
||||
|
||||
@staticmethod
|
||||
def _serialize(
|
||||
self,
|
||||
settings_row: SystemSetting,
|
||||
secrets_row: SystemSettingSecret,
|
||||
model_rows: dict[str, SystemModelSetting],
|
||||
@@ -853,6 +864,11 @@ class SettingsService:
|
||||
backup_model = model_rows["backup"]
|
||||
embedding_model = model_rows["embedding"]
|
||||
reranker_model = model_rows["reranker"]
|
||||
configured_slots = {
|
||||
slot
|
||||
for slot, model_row in model_rows.items()
|
||||
if self._decrypt_model_api_key(model_row.api_key_encrypted, slot=slot)
|
||||
}
|
||||
|
||||
return SettingsRead(
|
||||
companyForm={
|
||||
@@ -886,29 +902,37 @@ class SettingsService:
|
||||
"mainModel": main_model.model_name,
|
||||
"mainEndpoint": main_model.endpoint,
|
||||
"mainApiKey": "",
|
||||
"mainApiKeyConfigured": bool(main_model.api_key_encrypted),
|
||||
"mainApiKeyConfigured": "main" in configured_slots,
|
||||
"backupProvider": backup_model.provider,
|
||||
"backupModel": backup_model.model_name,
|
||||
"backupEndpoint": backup_model.endpoint,
|
||||
"backupApiKey": "",
|
||||
"backupApiKeyConfigured": bool(backup_model.api_key_encrypted),
|
||||
"backupApiKeyConfigured": "backup" in configured_slots,
|
||||
"embeddingProvider": embedding_model.provider,
|
||||
"embeddingModel": embedding_model.model_name,
|
||||
"embeddingEndpoint": embedding_model.endpoint,
|
||||
"embeddingApiKey": "",
|
||||
"embeddingApiKeyConfigured": bool(embedding_model.api_key_encrypted),
|
||||
"embeddingApiKeyConfigured": "embedding" in configured_slots,
|
||||
"rerankerProvider": reranker_model.provider,
|
||||
"rerankerModel": reranker_model.model_name,
|
||||
"rerankerEndpoint": reranker_model.endpoint,
|
||||
"rerankerApiKey": "",
|
||||
"rerankerApiKeyConfigured": bool(reranker_model.api_key_encrypted),
|
||||
"models": serialize_model_rows(model_rows),
|
||||
"rerankerApiKeyConfigured": "reranker" in configured_slots,
|
||||
"models": serialize_model_rows(
|
||||
model_rows,
|
||||
configured_slots=configured_slots,
|
||||
),
|
||||
},
|
||||
renderForm={
|
||||
"enabled": settings_row.onlyoffice_enabled,
|
||||
"publicUrl": settings_row.onlyoffice_public_url,
|
||||
"jwtSecret": "",
|
||||
"jwtSecretConfigured": bool(secrets_row.onlyoffice_jwt_secret_encrypted),
|
||||
"jwtSecretConfigured": bool(
|
||||
self._decrypt_saved_secret(
|
||||
secrets_row.onlyoffice_jwt_secret_encrypted,
|
||||
label="OnlyOffice JWT secret",
|
||||
)
|
||||
),
|
||||
},
|
||||
logForm={
|
||||
"level": settings_row.log_level,
|
||||
@@ -926,9 +950,14 @@ class SettingsService:
|
||||
"encryption": settings_row.smtp_encryption,
|
||||
"senderName": settings_row.sender_name,
|
||||
"senderAddress": settings_row.sender_address,
|
||||
"username": settings_row.smtp_username,
|
||||
"password": "",
|
||||
"passwordConfigured": bool(secrets_row.smtp_password_encrypted),
|
||||
"username": settings_row.smtp_username,
|
||||
"password": "",
|
||||
"passwordConfigured": bool(
|
||||
self._decrypt_saved_secret(
|
||||
secrets_row.smtp_password_encrypted,
|
||||
label="SMTP password",
|
||||
)
|
||||
),
|
||||
"alertEnabled": settings_row.alert_enabled,
|
||||
"digestEnabled": settings_row.digest_enabled,
|
||||
"digestTime": settings_row.digest_time,
|
||||
|
||||
40
server/tests/test_secret_box.py
Normal file
40
server/tests/test_secret_box.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core import secret_box
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"encoded_key",
|
||||
[
|
||||
"not-valid-base64!",
|
||||
base64.urlsafe_b64encode(b"short-key").decode("ascii"),
|
||||
],
|
||||
)
|
||||
def test_encrypt_rejects_invalid_existing_secret_key(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
encoded_key: str,
|
||||
) -> None:
|
||||
key_file = tmp_path / "settings.key"
|
||||
key_file.write_text(encoded_key, encoding="utf-8")
|
||||
monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", key_file)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
secret_box.encrypt_secret("probe")
|
||||
|
||||
assert key_file.read_text(encoding="utf-8") == encoded_key
|
||||
|
||||
|
||||
def test_encrypt_replaces_empty_key_file_with_valid_key(monkeypatch, tmp_path) -> None:
|
||||
key_file = tmp_path / "settings.key"
|
||||
key_file.write_text("", encoding="utf-8")
|
||||
monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", key_file)
|
||||
|
||||
encrypted = secret_box.encrypt_secret("probe")
|
||||
|
||||
assert secret_box.decrypt_secret(encrypted) == "probe"
|
||||
assert len(secret_box.read_secret_key()) == secret_box.KEY_BYTES
|
||||
@@ -123,6 +123,60 @@ def test_blank_secret_input_does_not_clear_saved_secret(monkeypatch) -> None:
|
||||
assert service.load_saved_model_api_key("main") == "persisted-key"
|
||||
|
||||
|
||||
def test_snapshot_marks_undecryptable_model_keys_as_unconfigured(monkeypatch) -> None:
|
||||
temp_dir = build_temp_secret_dir()
|
||||
monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", temp_dir / "settings.key")
|
||||
monkeypatch.setattr(Base.metadata, "create_all", lambda *args, **kwargs: None)
|
||||
monkeypatch.setenv("HERMES_HOME", str(temp_dir / ".hermes"))
|
||||
|
||||
with build_session(temp_dir / "settings.db") as db:
|
||||
service = SettingsService(db)
|
||||
payload = service.get_settings_snapshot().model_dump()
|
||||
payload["llmForm"]["mainApiKey"] = "main-secret"
|
||||
payload["llmForm"]["embeddingApiKey"] = "embedding-secret"
|
||||
payload["llmForm"]["rerankerApiKey"] = "reranker-secret"
|
||||
service.save_settings_snapshot(SettingsWrite(**payload))
|
||||
|
||||
secret_box.SECRET_KEY_FILE.unlink()
|
||||
secret_box.get_or_create_secret_key()
|
||||
|
||||
snapshot = service.get_settings_snapshot()
|
||||
configured_by_slot = {
|
||||
model.slot: model.apiKeyConfigured for model in snapshot.llmForm.models
|
||||
}
|
||||
|
||||
assert snapshot.llmForm.mainApiKeyConfigured is False
|
||||
assert snapshot.llmForm.embeddingApiKeyConfigured is False
|
||||
assert snapshot.llmForm.rerankerApiKeyConfigured is False
|
||||
assert configured_by_slot["main"] is False
|
||||
assert configured_by_slot["embedding"] is False
|
||||
assert configured_by_slot["reranker"] is False
|
||||
|
||||
|
||||
def test_snapshot_does_not_create_missing_secret_key_and_marks_secrets_unconfigured(monkeypatch) -> None:
|
||||
temp_dir = build_temp_secret_dir()
|
||||
monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", temp_dir / "settings.key")
|
||||
monkeypatch.setattr(Base.metadata, "create_all", lambda *args, **kwargs: None)
|
||||
monkeypatch.setenv("HERMES_HOME", str(temp_dir / ".hermes"))
|
||||
|
||||
with build_session(temp_dir / "settings.db") as db:
|
||||
service = SettingsService(db)
|
||||
payload = service.get_settings_snapshot().model_dump()
|
||||
payload["llmForm"]["mainApiKey"] = "main-secret"
|
||||
payload["renderForm"]["jwtSecret"] = "onlyoffice-secret"
|
||||
payload["mailForm"]["password"] = "smtp-secret"
|
||||
service.save_settings_snapshot(SettingsWrite(**payload))
|
||||
|
||||
secret_box.SECRET_KEY_FILE.unlink()
|
||||
|
||||
snapshot = service.get_settings_snapshot()
|
||||
|
||||
assert secret_box.SECRET_KEY_FILE.exists() is False
|
||||
assert snapshot.llmForm.mainApiKeyConfigured is False
|
||||
assert snapshot.renderForm.jwtSecretConfigured is False
|
||||
assert snapshot.mailForm.passwordConfigured is False
|
||||
|
||||
|
||||
def test_runtime_model_config_returns_decrypted_main_model(monkeypatch) -> None:
|
||||
temp_dir = build_temp_secret_dir()
|
||||
monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", temp_dir / "settings.key")
|
||||
|
||||
Reference in New Issue
Block a user