Files

295 lines
15 KiB
Python

#!/usr/bin/env python3
"""Synthetic wizard/Keychain tests. Run with the openconnect-lite Python."""
import base64
import importlib.util
import json
import os
import fcntl
from pathlib import Path
import pty
import select
import subprocess
import sys
import tempfile
import termios
import time
sys.dont_write_bytecode = True
# The existing tests/logging.py is a test entry point, not stdlib logging.
sys.path = [p for p in sys.path if Path(p).resolve() != Path(__file__).resolve().parent]
from unittest.mock import patch
import pyotp
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location("yandex_import", ROOT / "bin/lemana-vpn-import-yandex.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
SECRET = base64.b32encode(b"12345678901234567890").decode()
OTHER = "JBSWY3DPEHPK3PXP"
URI = f"otpauth://totp/Lemana:example?secret={SECRET}&algorithm=SHA1&digits=6&period=30"
ENTRY = {"name": "Lemana VPN", "secret": SECRET, "techInfo": URI}
ORIGINAL_CONFIG = 'LEMANA_VPN_USERNAME="example"\nLEMANA_VPN_MODE="bitwarden-auto"\nLEMANA_VPN_LOCAL_OTP="0"\nLEMANA_VPN_LOG_MAX_BYTES="12345"\n'
verify = pyotp.TOTP.verify
assert pyotp.TOTP(SECRET).at(59) == "287082" # RFC 6238 SHA1 vector, six digits.
class FakeKeyring:
def __init__(self, previous=OTHER):
self.values = {"example": "unchanged-password"}
if previous is not None:
self.values["totp/example"] = previous
self.writes = []
def get_password(self, service, account):
assert service == "openconnect-lite"
return self.values.get(account)
def set_password(self, service, account, value):
assert service == "openconnect-lite" and account == "totp/example"
self.writes.append((account, value))
self.values[account] = value
def delete_password(self, service, account):
assert service == "openconnect-lite" and account == "totp/example"
self.values.pop(account, None)
def wizard(directory, keyring, answers=None, entries=None, code="287082"):
backup = directory / "key_backup"
backup.write_text(json.dumps(entries if entries is not None else [
{"name": "Yandex account", "techInfo": "otpauth://yaotp/test?secret=not-supported"}, ENTRY]))
config = directory / "env"
config.write_text(ORIGINAL_CONFIG)
inputs = iter(answers if answers is not None else [str(backup), "2"])
output = []
def ask(prompt):
output.append(prompt)
try:
return next(inputs)
except StopIteration:
raise EOFError from None
def ask_secret(prompt):
assert any("LDAP: example" in line for line in output), output
assert any("Запись: Lemana VPN" in line for line in output), output
output.append(prompt)
if code is None:
raise EOFError
return code
failure = None
with patch.dict(sys.modules, {"keyring": keyring}), patch.object(pyotp.TOTP, "verify", lambda self, otp, valid_window: verify(self, otp, for_time=59, valid_window=valid_window)):
try:
module.configure("example", config, ask, ask_secret, output.append)
except (module.ImportFailure, EOFError, KeyboardInterrupt, OSError) as error:
failure = error
captured = "\n".join(output)
assert SECRET not in captured and OTHER not in captured and URI not in captured, captured
assert "unchanged-password" not in captured
return config, failure, output
with tempfile.TemporaryDirectory(prefix="lemana-yandex-") as temporary:
directory = Path(temporary)
keyring = FakeKeyring()
config, error, output = wizard(directory, keyring)
assert error is None, error
assert keyring.writes == [("totp/example", SECRET)], keyring.writes
assert keyring.values["example"] == "unchanged-password"
assert 'LEMANA_VPN_MODE="keychain-auto"' in config.read_text()
assert 'LEMANA_VPN_LOCAL_OTP="1"' in config.read_text()
assert 'LEMANA_VPN_LOG_MAX_BYTES="12345"' in config.read_text()
assert config.stat().st_mode & 0o777 == 0o600
assert not list(directory.glob(".otp-env-*"))
print("PASS wizard: mixed backup, explicit VPN selection, RFC code, one Keychain entry and private settings")
for answers, code in (([], "287082"), ([str(directory / "key_backup")], "287082"),
([str(directory / "key_backup"), ""], "287082"),
([str(directory / "key_backup"), "0"], "287082"),
(None, "000000"), (None, None)):
keyring = FakeKeyring()
config, error, _ = wizard(directory, keyring, answers=answers, code=code)
assert error is not None and keyring.writes == []
assert config.read_text() == ORIGINAL_CONFIG
print("PASS cancellation/invalid choice/wrong code: no writes")
variants = [URI.replace("//totp/", "//hotp/"), URI.replace("//totp/", "//yaotp/"),
URI.replace("SHA1", "SHA256"), URI.replace("digits=6", "digits=8"),
URI.replace("period=30", "period=60"), URI + "&secret=" + OTHER,
URI + "&algorithm=SHA1", URI + "&digits=6", URI + "&period=30",
URI.replace(SECRET, "NOT-BASE32"), URI.replace(SECRET, OTHER),
URI + "&counter=1", URI.replace("otpauth:", "https:")]
variants.extend([URI + "&encoder=steam", URI + "&chars=alpha&length=8"])
for uri in variants:
keyring = FakeKeyring()
config, error, _ = wizard(directory, keyring, entries=[dict(ENTRY, techInfo=uri)],
answers=[str(directory / "key_backup"), "1"])
assert isinstance(error, module.ImportFailure), (uri, error)
assert keyring.writes == [] and config.read_text() == ORIGINAL_CONFIG
assert SECRET not in str(error) and OTHER not in str(error)
print("PASS unsupported types/parameters, duplicate parameters, malformed/mismatched secrets")
for previous in (OTHER, None):
for failure in (OSError("secret backend detail"), KeyboardInterrupt()):
keyring = FakeKeyring(previous)
with patch.object(module.os, "replace", side_effect=failure):
config, error, _ = wizard(directory, keyring)
assert error is failure and config.read_text() == ORIGINAL_CONFIG
assert keyring.get_password("openconnect-lite", "totp/example") == previous
assert not list(directory.glob(".otp-env-*"))
keyring = FakeKeyring()
with patch.object(keyring, "set_password", side_effect=RuntimeError("backend secret")), patch.dict(sys.modules, {"keyring": keyring}):
try:
module.activate(SECRET, "example", config)
assert False, "expected backend failure"
except module.ImportFailure as error:
assert "вернуть прежний секрет не удалось" in str(error) and "backend secret" not in str(error)
assert config.read_text() == ORIGINAL_CONFIG
print("PASS config failure/interruption rollback and secret-safe rollback failure")
keyring = FakeKeyring()
real_replace = module.os.replace
def interrupted_after_replace(source, destination):
real_replace(source, destination)
raise KeyboardInterrupt
with patch.object(module.os, "replace", side_effect=interrupted_after_replace):
config, error, _ = wizard(directory, keyring)
assert isinstance(error, module.ImportFailure) and "уже сохранена" in str(error)
assert keyring.values["totp/example"] == SECRET and 'LEMANA_VPN_LOCAL_OTP="1"' in config.read_text()
keyring = FakeKeyring(None)
with patch.object(keyring, "set_password", return_value=None):
config, error, _ = wizard(directory, keyring)
assert isinstance(error, module.ImportFailure) and config.read_text() == ORIGINAL_CONFIG
keyring = FakeKeyring()
config.write_text(ORIGINAL_CONFIG.replace('USERNAME="example"', 'USERNAME="another"'))
with patch.dict(sys.modules, {"keyring": keyring}):
try:
module.activate(SECRET, "example", config)
assert False, "wrong saved account accepted"
except module.ImportFailure:
pass
assert not keyring.writes
config.write_text(ORIGINAL_CONFIG)
print("PASS commit-point consistency, silent backend failure and saved-account binding")
backup = directory / "bad_backup"
for data in (b"not JSON", b"{}", b"[]", b"[{\"techInfo\":1}]", b"x" * (module.MAX_BACKUP_BYTES + 1)):
backup.write_bytes(data)
try:
module.read_backup(backup)
assert False, "bad backup accepted"
except module.ImportFailure:
pass
assert module.display_name("hello\x1b[2J\nworld") == "hello[2Jworld"
assert module.display_name("A" * 110 + SECRET, {SECRET}) == ""
keyring = FakeKeyring()
config, error, output = wizard(directory, keyring, entries=[{"name": SECRET.lower(), "techInfo": URI}],
answers=[str(directory / "key_backup"), ""])
assert SECRET.lower() not in "\n".join(output) and not keyring.writes
config, error, output = wizard(directory, keyring, entries=[{
"name": SECRET, "secret": " " + SECRET + " ", "techInfo": URI.replace(SECRET, "%20" + SECRET + "%20")}],
answers=[str(directory / "key_backup"), ""])
assert SECRET not in "\n".join(output) and not keyring.writes
print("PASS malformed/oversized files and terminal-control sanitization")
# Installed entry point must fail without a controlling terminal, before writes.
sso = directory / "xdg/openconnect-lite"
sso.mkdir(parents=True)
sso.joinpath("config.toml").write_text('[credentials]\nusername="example"\n')
env = dict(os.environ, HOME=temporary, XDG_CONFIG_HOME=str(sso.parent), XDG_CONFIG_DIRS=str(sso.parent))
proc = subprocess.run([sys.executable, str(ROOT / "bin/lemana-vpn-import-yandex.py"),
"--username=example", "--config", str(config)],
stdin=subprocess.DEVNULL, capture_output=True, text=True, start_new_session=True, env=env)
assert proc.returncode == 1 and "Traceback" not in proc.stderr
assert config.read_text() == ORIGINAL_CONFIG
print("PASS no-terminal entry: bounded error, no writes")
# Drive the actual CLI + helper through an automated PTY and a fake backend.
fake = directory / "fake"
fake.mkdir()
fake.joinpath("security").write_text("#!/bin/sh\nexit 0\n")
fake.joinpath("security").chmod(0o755)
state = directory / "keyring.json"
state.write_text(json.dumps({"example": "unchanged-password"}))
fake.joinpath("keyring.py").write_text('''import json, os
from pathlib import Path
state = Path(os.environ["TEST_KEYRING"])
def get_password(service, account):
assert service == "openconnect-lite"
return json.loads(state.read_text()).get(account)
def set_password(service, account, password):
assert service == "openconnect-lite"
data = json.loads(state.read_text()); data[account] = password
state.write_text(json.dumps(data))
def delete_password(service, account):
data = json.loads(state.read_text()); data.pop(account, None)
state.write_text(json.dumps(data))
''')
directory.joinpath("key_backup").write_text(json.dumps([ENTRY]))
env = {key: value for key, value in env.items() if not key.startswith(("LEMANA_VPN_", "OPENCONNECT_LITE_"))}
env.update(PATH=str(fake) + ":" + os.environ["PATH"], PYTHONPATH=str(fake), TEST_KEYRING=str(state),
LEMANA_VPN_CONFIG_DIR=temporary, LEMANA_VPN_OC_PYTHON=sys.executable,
OPENCONNECT_LITE_CONFIG_DIR=str(sso), LEMANA_VPN_STATUS_DIR=str(directory / "status"))
master, slave = pty.openpty()
def controlling_terminal():
os.setsid()
fcntl.ioctl(slave, termios.TIOCSCTTY, 0)
proc = subprocess.Popen(["/bin/bash", str(ROOT / "bin/vpn-lemanapro.sh"), "--import-yandex-key"],
stdin=slave, stdout=slave, stderr=slave, env=env, preexec_fn=controlling_terminal)
os.close(slave)
output = b""
phone_code = pyotp.TOTP(SECRET).now()
inputs = [("Путь к файлу", str(directory / "key_backup")), ("Номер записи", "1"),
("для проверки и сохранения:", phone_code)]
deadline = time.monotonic() + 15
try:
while time.monotonic() < deadline:
if select.select([master], [], [], max(0, deadline - time.monotonic()))[0]:
try:
chunk = os.read(master, 8192)
except OSError:
break # PTY EOF on macOS.
if not chunk:
break
output += chunk
if inputs and inputs[0][0].encode() in output:
os.write(master, (inputs.pop(0)[1] + "\n").encode())
if proc.poll() is not None:
break
assert proc.wait(timeout=2) == 0, output.decode(errors="replace")
finally:
os.close(master)
if proc.poll() is None:
proc.kill()
proc.wait()
assert not inputs and SECRET.encode() not in output and phone_code.encode() not in output
assert json.loads(state.read_text()) == {"example": "unchanged-password", "totp/example": SECRET}
status = subprocess.run(["bash", str(ROOT / "bin/vpn-lemanapro.sh"), "--status", "--json"],
env=env, text=True, capture_output=True, check=True)
modules = json.loads(status.stdout)["modules"]
assert modules["local_otp"] and modules["mode"]["ready"] and not modules["bitwarden"]["enabled"]
override = subprocess.run(["bash", str(ROOT / "bin/vpn-lemanapro.sh"), "--status", "--json"],
env=dict(env, LEMANA_VPN_MODE="manual-keychain", LEMANA_VPN_LOCAL_OTP="0"), text=True, capture_output=True, check=True)
assert not json.loads(override.stdout)["modules"]["local_otp"]
config_after = config.read_text()
stored_after = state.read_text()
runtime_definitions = (ROOT / "bin/vpn-lemanapro.sh").read_text().split('\nif $STATUS_MODE; then', 1)[0]
mismatch = subprocess.run(["bash", "-c", runtime_definitions + '\n_can_prompt() { return 0; }; _import_yandex_key'],
env=dict(env, LEMANA_VPN_USERNAME="different-user"), text=True, capture_output=True)
assert mismatch.returncode != 0 and "отличается" in mismatch.stderr
mismatch = subprocess.run([sys.executable, str(ROOT / "bin/lemana-vpn-import-yandex.py"),
"--username=different-user", "--config", str(config)],
env=env, text=True, capture_output=True)
assert mismatch.returncode != 0 and "профиле SSO" in mismatch.stderr
assert config.read_text() == config_after and state.read_text() == stored_after
print("PASS real CLI/PTY import → fake Keychain → persisted mode → status; hidden code; override and account guards")
print("yandex import checks ok")