#!/usr/bin/env python3 """登录凭据的安全存储测试(纯标准库层)。 重点是三件事: 1. **不再明文落盘**:settings.json 里不出现 token,凭据文件里也不是原文; 2. **强度如实汇报**:DPAPI → 受保护;两个加密后端都不可用 → ``plain`` 且 ``is_secure()`` 为 False(界面据此提示); 3. **换服务器不串凭据**:令牌只交还给签发它的那个 base_url。 """ import base64 import json import os import sys import unittest from helpers import VFSTestCase from svfs_client import credentials, paths as client_paths, settings assert sys.modules.get("helpers") is not None class _FakeKeyring: """keyring 的最小替身(够 credentials 用:后端名 + 三件套)。""" def __init__(self): self.store = {} def get_keyring(self): return "fake-keyring" def set_password(self, service, username, password): self.store[(service, username)] = password def get_password(self, service, username): return self.store.get((service, username)) def delete_password(self, service, username): self.store.pop((service, username), None) class CredentialsTest(VFSTestCase): BASE_URL = "http://10.0.0.5:8000" def setUp(self): super().setUp() self.base = client_paths.ensure_layout(self.path("data")) # ---- 通用(任何后端都要满足) ------------------------------------ def test_roundtrip_and_base_url_gate(self): credentials.save(self.base, "tok-1", base_url=self.BASE_URL, username="admin") self.assertEqual(credentials.load(self.base, self.BASE_URL), "tok-1") # 换服务器:旧令牌不交还(它只对签发它的服务器有效) self.assertEqual(credentials.load(self.base, "http://other:9000"), "") # 不指定服务器时不设门槛(调用方明确表示"就是当前这个") self.assertEqual(credentials.load(self.base), "tok-1") def test_token_never_in_settings(self): settings.save(self.base, {**settings.DEFAULTS, "base_url": self.BASE_URL, "auth_user": "admin"}) credentials.save(self.base, "tok-secret", base_url=self.BASE_URL) raw = open(client_paths.settings_file(self.base), encoding="utf-8").read() self.assertNotIn("tok-secret", raw) def test_empty_token_clears(self): credentials.save(self.base, "tok-1", base_url=self.BASE_URL) credentials.save(self.base, "", base_url=self.BASE_URL) self.assertEqual(credentials.load(self.base, self.BASE_URL), "") self.assertFalse(os.path.exists(client_paths.credentials_file(self.base))) def test_clear_removes_credential_file(self): credentials.save(self.base, "tok-1", base_url=self.BASE_URL) self.assertTrue(os.path.exists(client_paths.credentials_file(self.base))) credentials.clear(self.base) self.assertEqual(credentials.load(self.base, self.BASE_URL), "") self.assertFalse(os.path.exists(client_paths.credentials_file(self.base))) def test_corrupt_file_reads_as_logged_out(self): credentials.save(self.base, "tok-1", base_url=self.BASE_URL) with open(client_paths.credentials_file(self.base), "w", encoding="utf-8") as handle: handle.write("{ 不是 JSON") self.assertEqual(credentials.load(self.base, self.BASE_URL), "") def test_migrate_moves_settings_token(self): settings.save(self.base, {**settings.DEFAULTS, "base_url": self.BASE_URL, "auth_token": "tok-legacy", "auth_user": "admin"}) self.assertEqual(credentials.migrate(self.base), credentials.backend(self.base)) self.assertEqual(credentials.load(self.base, self.BASE_URL), "tok-legacy") # 原字段清空(落盘的内容里不再有 token) self.assertEqual(settings.load(self.base)["auth_token"], "") raw = open(client_paths.settings_file(self.base), encoding="utf-8").read() self.assertNotIn("tok-legacy", raw) # 幂等:再迁一次什么也不做 self.assertEqual(credentials.migrate(self.base), "") def test_migrate_without_legacy_token_is_noop(self): settings.save(self.base, {**settings.DEFAULTS, "base_url": self.BASE_URL}) self.assertEqual(credentials.migrate(self.base), "") class PlainBackendTest(CredentialsTest): """两个加密后端都不可用时的兜底路径(仍是原来的通用断言)。""" def setUp(self): super().setUp() # 关掉 DPAPI 与 keyring:只剩 plain self._patch("_dpapi_usable", lambda: False) self._patch("_keyring_usable", lambda: False) self._patch("_keyring", lambda: None) def _patch(self, name, value): original = getattr(credentials, name) setattr(credentials, name, value) self.addCleanup(setattr, credentials, name, original) def test_reports_insecure(self): self.assertEqual(credentials.backend(self.base), "plain") self.assertFalse(credentials.is_secure(self.base)) self.assertIn("明文", credentials.describe(self.base)) def test_encryption_failure_degrades_instead_of_losing_token(self): """加密后端当场失败(DPAPI 被策略禁、keyring 后端挂)时降级明文, 但不能把用户刚登录拿到的令牌弄丢。""" def boom(data, protect): raise OSError("DPAPI 被组策略禁用") self._patch("_dpapi_usable", lambda: True) self._patch("_dpapi_call", boom) self.assertEqual( credentials.save(self.base, "tok-x", base_url=self.BASE_URL), "plain") self.assertEqual(credentials.load(self.base, self.BASE_URL), "tok-x") class KeyringBackendTest(CredentialsTest): """keyring 可用(非 Windows 的常规路径)——文件里不该有令牌。""" def setUp(self): super().setUp() self.fake = _FakeKeyring() self._patch("_dpapi_usable", lambda: False) self._patch("_keyring", lambda: self.fake) self._patch("_keyring_usable", lambda: True) def _patch(self, name, value): original = getattr(credentials, name) setattr(credentials, name, value) self.addCleanup(setattr, credentials, name, original) def test_token_lives_in_keyring_not_in_file(self): self.assertEqual(credentials.backend(self.base), "keyring") self.assertTrue(credentials.is_secure(self.base)) credentials.save(self.base, "tok-keyring", base_url=self.BASE_URL) self.assertEqual(credentials.load(self.base, self.BASE_URL), "tok-keyring") raw = open(client_paths.credentials_file(self.base), encoding="utf-8").read() self.assertNotIn("tok-keyring", raw) self.assertEqual( self.fake.store[("svfs-client", self.BASE_URL)], "tok-keyring") def test_load_is_empty_when_keyring_backend_disappears(self): credentials.save(self.base, "tok-keyring", base_url=self.BASE_URL) self._patch("_keyring", lambda: None) self.assertEqual(credentials.load(self.base, self.BASE_URL), "") def test_clear_removes_keyring_entry(self): credentials.save(self.base, "tok-keyring", base_url=self.BASE_URL) credentials.clear(self.base) self.assertEqual(self.fake.store, {}) @unittest.skipUnless(os.name == "nt", "DPAPI 只在 Windows 上存在") class DpapiBackendTest(CredentialsTest): """Windows 默认后端:文件里是密文,且只有本机本用户能解。""" def test_default_backend_is_dpapi(self): self.assertEqual(credentials.backend(self.base), "dpapi") self.assertTrue(credentials.is_secure(self.base)) self.assertIn("DPAPI", credentials.describe(self.base)) def test_stored_bytes_are_ciphertext(self): credentials.save(self.base, "tok-secret-value", base_url=self.BASE_URL) with open(client_paths.credentials_file(self.base), "rb") as handle: raw = handle.read() self.assertNotIn(b"tok-secret-value", raw) record = json.loads(raw.decode("utf-8")) self.assertEqual(record["backend"], "dpapi") self.assertTrue(record["token"]) # 存的是密文的 base64,不是令牌本身 self.assertNotEqual(base64.b64decode(record["token"]), b"tok-secret-value") def test_tampered_ciphertext_reads_as_logged_out(self): credentials.save(self.base, "tok-secret-value", base_url=self.BASE_URL) file = client_paths.credentials_file(self.base) record = json.load(open(file, encoding="utf-8")) blob = bytearray(base64.b64decode(record["token"])) blob[len(blob) // 2] ^= 0xFF # 篡改一个字节 record["token"] = base64.b64encode(bytes(blob)).decode("ascii") with open(file, "w", encoding="utf-8") as handle: json.dump(record, handle) # 解不开就当没登录(不能崩,也不能返回垃圾) self.assertEqual(credentials.load(self.base, self.BASE_URL), "") if __name__ == "__main__": unittest.main()