diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8e25854..213d9bd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -118,6 +118,8 @@ jobs: uses: softprops/action-gh-release@v2 with: name: ${{ github.ref_name }} + prerelease: ${{ contains(github.ref_name, 'b') || contains(github.ref_name, 'rc') }} + make_latest: ${{ (contains(github.ref_name, 'b') || contains(github.ref_name, 'rc')) && 'false' || 'true' }} body_path: RELEASE_NOTES.md files: release-files/*.exe fail_on_unmatched_files: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 25da69f..d502592 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ This file owns the release history for `ScanLink`. +## v0.7.5b1 + +- **Windows x86 Crypto Wheels**: Resolve cryptography from compatible binary wheels during executable builds, avoiding an unsupported latest-source Rust/OpenSSL compilation on 32-bit Python. + +- **Windows First-Launch Consent Gate**: Exercise the specific ScanLink certificate Yes prompt in installed-app tests instead of bypassing trust; stress 120 normally verified HTTPS requests per architecture and log certificate setup stages. + +- **Beta Publication**: Mark beta/RC tags as GitHub prereleases and keep the stable latest pointer unchanged. + +- **Automatic Windows HTTPS Setup**: Provision and trust a localhost-only server certificate for the current Windows user during normal startup; encrypt its key with a DPAPI-protected password, repair missing trust, and renew within 30 days of expiry. +- **Background Setup And Renewal**: Preserve HTTP scanning while preparing HTTPS, expose `https_setup` in `/health`, swap renewed identities for new TLS handshakes, and offer native Retry plus a tray repair action on setup failures. +- **Installer HTTPS Verification**: Verify trusted HTTPS without bypassing certificate checks and reuse the identity after restart in both x86/x64 installed-app smoke gates; remove the optional mkcert build dependency. + ## v0.7.4 - **Windows Installed-App Verification**: Added x86/x64 installer smoke tests for installed assets, HKCU startup/protocol registration, packaged runtime identity, live REST endpoints, protocol launch, and graceful shutdown before artifact publication. diff --git a/README.md b/README.md index b79a24f..34f1527 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ ScanLink is a robust, lightweight system-tray application that bridges the gap b - **ReportLab**: Professional PDF generation engine. - **PyStray**: System tray icon and menu integration. - **Tkinter**: Native GUI dialogs for scanner selection. -- **mkcert**: Auto-generates trusted localhost SSL certificates. +- **cryptography + Windows CryptoAPI/DPAPI**: Generate a localhost identity, trust its server certificate for the current user, and protect its private key. - **PyInstaller**: Compilation into standalone Windows executable. - **Inno Setup**: Professional Windows installer generation. @@ -48,9 +48,13 @@ ScanLink is a robust, lightweight system-tray application that bridges the gap b ### Method 1: Windows Installer (Recommended) 1. Download and run `ScanLinkSetup.exe`. -2. Follow the installation wizard (installs to `Program Files (x86)\ScanLink`). +2. Follow the installation wizard (installs to your Windows user profile). 3. Launch **ScanLink** from your Start Menu or Desktop. -4. The app will appear in your system tray (near the clock). +4. The app appears in your system tray and sets up its secure connection automatically. Approve any Windows or browser prompt, then scan from your application. No commands or certificate imports are needed. + +If Windows blocks setup, ScanLink offers **Retry**. You can also right-click its +tray icon and choose **Retry secure connection setup**. If your organization +manages this computer and setup stays blocked, contact your support team. ### Method 2: Portable Executable 1. Unzip the release package. @@ -90,7 +94,11 @@ ScanLink exposes a local REST API for browser apps: - `GET http://127.0.0.1:5000/scan/` - `GET http://127.0.0.1:5000/scan//result` -HTTPS is also available at `https://127.0.0.1:5443` when trusted localhost certificates exist. +HTTPS is prepared automatically at `https://127.0.0.1:5443` on Windows. Healthy +identities are reused and renewed before expiry. `/health.https_setup` reports +`preparing`, `ready`, or `failed`; HTTP remains available while setup runs. +The certificate is a localhost-only server identity, not a certificate authority. +See [architecture](docs/architecture.md#local-transports) for trust and key storage. Minimal browser flow: diff --git a/VERSION b/VERSION index 0a1ffad..728cb3b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.4 +0.7.5b1 diff --git a/build_exe.py b/build_exe.py index 11db276..a859a32 100644 --- a/build_exe.py +++ b/build_exe.py @@ -17,6 +17,7 @@ ("pystray", "pystray"), ("requests", "requests"), ("twain", "pytwain"), + ("cryptography", "cryptography"), ) @@ -36,11 +37,6 @@ def read_version(): r""" ScanLink build script. -Prerequisite: -1. Download mkcert.exe from https://github.com/FiloSottile/mkcert/releases - and place it in the project root before building if you want optional - trusted localhost HTTPS support. - Usage: python build_exe.py python build_exe.py --arch x64 @@ -76,7 +72,7 @@ def ensure_venv(arch, base_python): if os.path.exists(REQUIREMENTS_FILE): print(f"Installing requirements into {arch} venv ...") subprocess.run( - [python_exe, "-m", "pip", "install", "--quiet", "-r", REQUIREMENTS_FILE], + [python_exe, "-m", "pip", "install", "--quiet", "--only-binary=cryptography", "-r", REQUIREMENTS_FILE], check=True, ) print(f"{arch} requirements installed.") @@ -197,10 +193,6 @@ def build_pyinstaller_args(build_name): pyinstaller_args.append("--icon=app_icon.ico") pyinstaller_args.append(add_data_arg("app_icon.ico")) - if os.path.exists("mkcert.exe"): - pyinstaller_args.append(add_data_arg("mkcert.exe")) - print("mkcert.exe will be bundled for optional HTTPS certificate generation") - return pyinstaller_args @@ -227,13 +219,6 @@ def main(): requested_arches = resolve_requested_arches(args.arch) python_by_arch = {} - if not os.path.exists("mkcert.exe"): - print("WARNING: mkcert.exe not found!") - print("Download from: https://github.com/FiloSottile/mkcert/releases") - print("Place mkcert-vX.X.X-windows-amd64.exe as 'mkcert.exe' in this folder.") - print("Continuing without mkcert (HTTP scanning will still work; trusted HTTPS will be unavailable).") - print() - for arch in requested_arches: python_executable = resolve_python_for_arch(arch, args) if not python_executable: diff --git a/certificates.py b/certificates.py new file mode 100644 index 0000000..2002c7a --- /dev/null +++ b/certificates.py @@ -0,0 +1,328 @@ +"""Per-user localhost identity and Windows trust for the loopback API.""" + +import base64 +import ctypes +from ctypes import wintypes +from datetime import datetime, timedelta, timezone +import ipaddress +import json +import logging +import os +import shutil +from pathlib import Path +import ssl +import uuid + +from cryptography import x509 +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID + +logger = logging.getLogger("ScanLink") +RENEW_BEFORE = timedelta(days=30) +SERVER_AUTH = ExtendedKeyUsageOID.SERVER_AUTH.dotted_string + + +class DataBlob(ctypes.Structure): + _fields_ = [("size", wintypes.DWORD), ("data", ctypes.POINTER(ctypes.c_ubyte))] + + +def _windows_crypto(): + if os.name != "nt": + raise OSError("Automatic certificate setup requires Windows") + return ctypes.WinDLL("crypt32", use_last_error=True) + + +def _protect_secret(value, decrypt=False): + crypto = _windows_crypto() + buffer = ctypes.create_string_buffer(value) + source = DataBlob(len(value), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ubyte))) + target = DataBlob() + # CRYPTPROTECT_UI_FORBIDDEN: the secret is bound to this Windows user's login. + arguments = [ + ctypes.POINTER(DataBlob), + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_void_p, + wintypes.DWORD, + ctypes.POINTER(DataBlob), + ] + crypto.CryptUnprotectData.argtypes = arguments + crypto.CryptUnprotectData.restype = wintypes.BOOL + crypto.CryptProtectData.argtypes = [ + ctypes.POINTER(DataBlob), + wintypes.LPCWSTR, + *arguments[2:], + ] + crypto.CryptProtectData.restype = wintypes.BOOL + if decrypt: + ok = crypto.CryptUnprotectData( + ctypes.byref(source), None, None, None, None, 1, ctypes.byref(target) + ) + else: + ok = crypto.CryptProtectData( + ctypes.byref(source), + "ScanLink HTTPS", + None, + None, + None, + 1, + ctypes.byref(target), + ) + if not ok: + raise ctypes.WinError(ctypes.get_last_error()) + kernel = ctypes.WinDLL("kernel32", use_last_error=True) + kernel.LocalFree.argtypes = [ctypes.c_void_p] + kernel.LocalFree.restype = ctypes.c_void_p + try: + return ctypes.string_at(target.data, target.size) + finally: + kernel.LocalFree(target.data) + + +def _is_trusted(der): + if os.name != "nt": + return False + return any( + encoded == der + and encoding == "x509_asn" + and (trust is True or SERVER_AUTH in trust) + for encoded, encoding, trust in ssl.enum_certificates("ROOT") + ) + + +def _install_trust(der): + crypto = _windows_crypto() + crypto.CertOpenStore.argtypes = [ + ctypes.c_void_p, + wintypes.DWORD, + ctypes.c_void_p, + wintypes.DWORD, + ctypes.c_void_p, + ] + crypto.CertOpenStore.restype = ctypes.c_void_p + crypto.CertAddEncodedCertificateToStore.argtypes = [ + ctypes.c_void_p, + wintypes.DWORD, + ctypes.c_void_p, + wintypes.DWORD, + wintypes.DWORD, + ctypes.c_void_p, + ] + crypto.CertAddEncodedCertificateToStore.restype = wintypes.BOOL + crypto.CertCloseStore.argtypes = [ctypes.c_void_p, wintypes.DWORD] + crypto.CertCloseStore.restype = wintypes.BOOL + # SYSTEM_W provider, CURRENT_USER, OPEN_EXISTING. Never change machine trust. + store = crypto.CertOpenStore( + 10, 0, None, 0x00010000 | 0x00004000, ctypes.c_wchar_p("ROOT") + ) + if not store: + raise ctypes.WinError(ctypes.get_last_error()) + try: + logger.info("Requesting Windows current-user trust for ScanLink localhost") + encoded = ctypes.create_string_buffer(der) + # ADD_NEW: do not replace or remove another certificate. + if not crypto.CertAddEncodedCertificateToStore( + store, 1, encoded, len(der), 1, None + ): + if not _is_trusted(der): + raise ctypes.WinError(ctypes.get_last_error()) + finally: + crypto.CertCloseStore(store, 0) + if not _is_trusted(der): + raise OSError("Windows did not trust the ScanLink certificate") + logger.info("Windows current-user certificate trust verified") + + +class LocalIdentity: + def __init__(self, app_dir): + self.root = Path(app_dir) / "https" + self.pointer = self.root / "active.json" + + def _read(self): + identity = json.loads(self.pointer.read_text(encoding="utf-8"))["identity"] + if ( + not isinstance(identity, str) + or len(identity) != 32 + or any(c not in "0123456789abcdef" for c in identity) + ): + raise ValueError("Invalid ScanLink certificate identity") + directory = self.root / identity + certificate = x509.load_pem_x509_certificate( + (directory / "localhost.pem").read_bytes() + ) + certificate.verify_directly_issued_by(certificate) + if certificate.subject != x509.Name( + [x509.NameAttribute(NameOID.COMMON_NAME, "ScanLink localhost")] + ): + raise ValueError("Unexpected ScanLink certificate subject") + if certificate.extensions.get_extension_for_class( + x509.BasicConstraints + ).value.ca: + raise ValueError("ScanLink must not install a certificate authority") + names = certificate.extensions.get_extension_for_class( + x509.SubjectAlternativeName + ).value + if set(names.get_values_for_type(x509.DNSName)) != {"localhost"} or set( + names.get_values_for_type(x509.IPAddress) + ) != {ipaddress.ip_address("127.0.0.1"), ipaddress.ip_address("::1")}: + raise ValueError("Unexpected ScanLink certificate names") + if set( + certificate.extensions.get_extension_for_class(x509.ExtendedKeyUsage).value + ) != {ExtendedKeyUsageOID.SERVER_AUTH}: + raise ValueError("Unexpected ScanLink certificate purpose") + password = _protect_secret( + (directory / "password.dpapi").read_bytes(), decrypt=True + ) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.minimum_version = ssl.TLSVersion.TLSv1_2 + context.load_cert_chain(str(directory / "localhost.pem"), password=password) + return certificate, context + + def context(self): + certificate, context = self._read() + now = datetime.now(timezone.utc) + if ( + not certificate.not_valid_before_utc + <= now + < certificate.not_valid_after_utc + ): + raise ValueError("ScanLink certificate is not currently valid") + if not _is_trusted(certificate.public_bytes(serialization.Encoding.DER)): + raise OSError("ScanLink certificate is not trusted") + return context + + def ensure(self): + try: + certificate, context = self._read() + now = datetime.now(timezone.utc) + if ( + certificate.not_valid_before_utc <= now + and certificate.not_valid_after_utc > now + RENEW_BEFORE + ): + needs_renewal = False + else: + needs_renewal = True + except ( + OSError, + ValueError, + KeyError, + TypeError, + InvalidSignature, + x509.ExtensionNotFound, + ): + needs_renewal = True + if needs_renewal: + logger.info("Preparing a new ScanLink localhost identity") + return self._create() + der = certificate.public_bytes(serialization.Encoding.DER) + if not _is_trusted(der): + _install_trust(der) + return context + + def _create(self): + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + now = datetime.now(timezone.utc) + name = x509.Name( + [x509.NameAttribute(NameOID.COMMON_NAME, "ScanLink localhost")] + ) + certificate = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(minutes=5)) + .not_valid_after(now + timedelta(days=365)) + .add_extension( + x509.BasicConstraints(ca=False, path_length=None), critical=True + ) + .add_extension( + x509.KeyUsage( + digital_signature=True, + content_commitment=False, + key_encipherment=True, + data_encipherment=False, + key_agreement=False, + key_cert_sign=False, + crl_sign=False, + encipher_only=None, + decipher_only=None, + ), + critical=True, + ) + .add_extension( + x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), critical=False + ) + .add_extension( + x509.SubjectAlternativeName( + [ + x509.DNSName("localhost"), + x509.IPAddress(ipaddress.ip_address("127.0.0.1")), + x509.IPAddress(ipaddress.ip_address("::1")), + ] + ), + critical=False, + ) + .sign(key, hashes.SHA256()) + ) + logger.info("Localhost server certificate generated") + identity = uuid.uuid4().hex + directory = self.root / identity + directory.mkdir(parents=True, mode=0o700) + password = base64.b64encode(os.urandom(32)) + secret = _protect_secret(password) + logger.info("Localhost private-key password protected with Windows DPAPI") + bundle = certificate.public_bytes( + serialization.Encoding.PEM + ) + key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.BestAvailableEncryption(password), + ) + (directory / "localhost.pem").write_bytes(bundle) + (directory / "password.dpapi").write_bytes(secret) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.minimum_version = ssl.TLSVersion.TLSv1_2 + context.load_cert_chain(str(directory / "localhost.pem"), password=password) + # Immutable identity directories retain previous and interrupted attempts. + pending = directory / "active.json" + pending.write_text(json.dumps({"identity": identity}), encoding="utf-8") + if self.pointer.exists(): + previous = directory / "previous-active.json" + previous.write_bytes(self.pointer.read_bytes()) + os.replace(pending, self.pointer) + _install_trust(certificate.public_bytes(serialization.Encoding.DER)) + if (directory / "previous-active.json").exists(): + try: + old_identity = json.loads( + (directory / "previous-active.json").read_text() + )["identity"] + if ( + isinstance(old_identity, str) + and len(old_identity) == 32 + and all(c in "0123456789abcdef" for c in old_identity) + ): + old_directory = self.root / old_identity + archive = self.root.parent / ".xclude" / "https" / old_identity + if old_directory.is_dir() and old_directory != directory: + archive.parent.mkdir(parents=True, exist_ok=True) + suffix = 1 + original_archive = archive + while archive.exists(): + archive = original_archive.with_name( + f"{original_archive.name}-{suffix}" + ) + suffix += 1 + shutil.move(str(old_directory), str(archive)) + except (OSError, ValueError, KeyError): + logger.warning( + "Could not archive previous HTTPS identity", exc_info=True + ) + logger.info( + "ScanLink localhost certificate ready; expires %s", + certificate.not_valid_after_utc.isoformat(), + ) + return context diff --git a/docs/api.md b/docs/api.md index c4f89ad..10754c6 100644 --- a/docs/api.md +++ b/docs/api.md @@ -207,3 +207,11 @@ Allowed origins come from `%LOCALAPPDATA%\ScanLink\config.json`: ``` When `cors_origins` is not `["*"]`, the request `Origin` must exactly match an entry in the list. + +## Automatic HTTPS setup status (0.7.5b1) + +`GET /health` adds `https_setup`: `preparing` while background certificate setup +runs, `ready` after trusted HTTPS starts, and `failed` after a setup/listener +failure. Existing `http_enabled` and `https_enabled` remain listener flags. +Older clients may ignore the new field; newer clients must tolerate its absence +on older helpers. Existing HTTP scan jobs remain usable while setup runs. diff --git a/docs/architecture.md b/docs/architecture.md index 0c4d964..07989c3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -13,6 +13,7 @@ ScanLink is a Windows tray application that exposes a local browser API and isol | `scanner.py` | TWAIN scanner manager. Lists sources, opens the selected scanner, acquires pages, and generates PDFs. | | `tray.py` | System tray icon and menu actions. Controls source, side, color mode, resolution, diagnostics, startup, scanner UI, paper protection, and quit. | | `protocol_handler.py` | Parses `scanlink://` URLs and forwards protocol launches to an existing local instance. | +| `certificates.py` | Per-user localhost identity, Windows trust, DPAPI key-password protection, and renewal. | | `utils.py` | Shared logging, config, version, SSL certificate, startup, and resource helpers. | | `gui/dialogs.py` | Tk dialogs for scanner selection and diagnostics output. | @@ -157,20 +158,41 @@ HTTPS: 127.0.0.1:5443 ``` -HTTPS starts only when these files exist and load successfully: - -```text -%LOCALAPPDATA%\ScanLink\localhost.pem -%LOCALAPPDATA%\ScanLink\localhost-key.pem -``` - -They can be generated with: - -```powershell -ScanLink.exe --generate-ssl -``` - -when `mkcert.exe` is available in the bundled resources. +On normal startup the server thread starts HTTP first, then prepares a trusted +HTTPS identity automatically. A one-year self-signed **server** certificate +(`BasicConstraints CA=False`, serverAuth EKU, no certificate-signing usage) +covers only `localhost`, `127.0.0.1`, and `::1`. Windows CryptoAPI adds that +specific certificate to **CurrentUser\Root**; no general-purpose CA, shared key, +external certificate service, administrator CLI or machine-wide trust is used. +Browser/organization policy can still block user-installed trust. + +`certificates.LocalIdentity` keeps immutable identities under +`%LOCALAPPDATA%\ScanLink\https\\`. `localhost.pem` contains the +public certificate and an encrypted PKCS#8 private key; `password.dpapi` holds +the random key password encrypted for this Windows login. `active.json` points +to the current identity. Key material is validated before activation; HTTPS is enabled only after trust verification. +failed trust installation is reported as failure and retried with the same +identity. Superseded identities move to `.xclude/https/` in the app data folder. +Existing legacy mkcert credentials are not used or removed. + +Each launch checks validity, names, purpose, key matching and Windows root-store +membership. Missing trust is repaired without replacing the key. Identities +within 30 days of expiry are renewed. The server rechecks hourly and selects +the current SSLContext on each new TLS handshake, preserving active connections. +HTTP scanning stays available during setup. `/health.https_setup` is +`preparing`, `ready`, or `failed`. A failed first attempt opens a native Retry +prompt; the tray has **Retry secure connection setup** for later repairs. +Automatic hourly retries do not repeatedly open dialogs. + +`--generate-ssl` remains a support/development compatibility command; ordinary +installation and use do not require it. The installer launches the normal app, +which performs the same setup for installed and portable builds. Uninstall +keeps the encrypted identity so a reinstall can reuse it. The generated certificate has server-only usage and no certificate-signing +permission. Nothing is sent to the application VM. + +The paired DjangoLux patch is 1.15.1b1; the REST API remains compatible with older +clients. Browser local-network permission, CSP and an insecure application +origin are separate from ScanLink certificate provisioning. ## Single Instance and Protocol Handling diff --git a/docs/building.md b/docs/building.md index 7ad8da6..1333d1e 100644 --- a/docs/building.md +++ b/docs/building.md @@ -8,7 +8,7 @@ This guide covers local development, executable builds, and installer builds. - Python 3.10+ - TWAIN-compatible scanner and driver for real scan testing - Inno Setup for installer builds -- Optional: `mkcert.exe` in the project root for trusted localhost HTTPS support +- `cryptography` (installed by requirements/build helper); Windows CryptoAPI and DPAPI are built into Windows Install Python dependencies: @@ -22,7 +22,7 @@ Run from source: python main.py ``` -Generate trusted localhost certificates and exit: +Normal startup provisions and renews trusted localhost HTTPS automatically. For support/development only, provision the identity and exit: ```powershell python main.py --generate-ssl @@ -43,7 +43,7 @@ After each x86/x64 installer is compiled in GitHub Actions, `tests/windows_install_smoke.ps1` silently installs it on the Windows runner and checks installed assets, the Start menu shortcut, HKCU startup and `scanlink://` registration, packaged version/architecture, live REST responses, protocol -launch, and graceful shutdown. +launch, normal Windows-verified HTTPS, identity reuse after restart, and graceful shutdown. No certificate verification bypass is used. ## Icon Generation @@ -206,3 +206,21 @@ Open a protocol link: ```powershell Start-Process "scanlink://scan?docId=DOC-123" ``` + +## Beta installers + +A beta tag such as `v0.7.5b1` must match VERSION and the changelog section. +The release workflow publishes both architecture installers as a GitHub +prerelease and leaves the stable latest release unchanged. Run the Windows +unit/build/installed-app gates through the PR before tagging; publication also +runs those gates. Paired DjangoLux 1.15.1b1 accepts these version identifiers. + +The installed-app smoke test approves only the Windows Yes prompt owned by the +installed ScanLink executable and naming `ScanLink localhost`. It does not +preinstall a certificate or disable verification. It then sends 120 HTTPS +requests in batches of 12 before checking restart reuse. Stage logging pinpoints +blocked setup without showing private key material. + +The build wrapper requires a binary cryptography wheel. Pip selects the newest +compatible wheel per Python architecture instead of attempting a source build +of a newer release that no longer publishes Windows x86 wheels. diff --git a/docs/developer-guide.md b/docs/developer-guide.md index 10967fe..a616cbd 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -21,7 +21,7 @@ Typical flow: The local API listens on: - `http://127.0.0.1:5000` -- `https://127.0.0.1:5443` when trusted localhost certificates are available +- `https://127.0.0.1:5443` after automatic per-user Windows certificate setup The bundled `static/scanlink.js` helper tries HTTPS first and falls back to HTTP. @@ -205,3 +205,13 @@ Downloading `/scan//result` consumes the stored PDF. A second download f - [API Reference](api.md) - [Architecture](architecture.md) - [Building and Release](building.md) + +## Automatic HTTPS status + +Normal startup provisions trusted localhost HTTPS in the background. Operators +only install, open ScanLink and approve any required Windows/browser prompt. +`GET /health` includes `https_setup`: `preparing`, `ready`, or `failed`. +HTTP remains available during setup; clients should not block working HTTP +scans while waiting for HTTPS. A native Retry dialog and tray repair action +handle failed setup without a command prompt. See the architecture guide for +DPAPI-protected keys, trust verification, hourly checks and renewal. diff --git a/main.py b/main.py index cccde38..cd30170 100644 --- a/main.py +++ b/main.py @@ -4,6 +4,7 @@ import sys import threading import tkinter as tk +from tkinter import messagebox from gui.dialogs import init_gui from protocol_handler import ( @@ -15,7 +16,7 @@ from scan_runtime import run_scan_job from server import run_server from tray import TrayApp -from utils import APP_VERSION, ensure_ssl_certificates, get_logger, is_ssl_available, setup_logging +from utils import APP_VERSION, ensure_ssl_certificates, get_logger, setup_logging from worker import ScanWorker, set_status_callback @@ -24,7 +25,9 @@ def main(): logger = setup_logging() logger.info("ScanLink v%s starting", APP_VERSION) - parser = argparse.ArgumentParser(description="ScanLink - Scanner Bridge for Web Apps") + parser = argparse.ArgumentParser( + description="ScanLink - Scanner Bridge for Web Apps" + ) parser.add_argument("url", nargs="?", help="Protocol URL (scanlink://...)") parser.add_argument("--scan-worker-dir", help=argparse.SUPPRESS) parser.add_argument( @@ -58,11 +61,6 @@ def main(): logger.info("Another ScanLink instance is already running. Exiting.") return 0 - if is_ssl_available(): - logger.info("Trusted localhost HTTPS certificates are available") - else: - logger.info("Trusted localhost HTTPS certificates not found; HTTP loopback listener will remain available") - root = tk.Tk() root.withdraw() root.update() @@ -71,16 +69,31 @@ def main(): shutdown_event = threading.Event() gui_task_queue = queue.Queue() + https_retry_event = threading.Event() worker = ScanWorker(gui_task_queue) + + def offer_https_retry(): + def show_setup_message(): + if messagebox.askretrycancel( + "ScanLink connection setup", + "ScanLink could not finish setting up its secure connection. " + "Approve any Windows security prompt, then choose Retry. " + "If your organization manages this computer, ask your support team for help.", + parent=root, + ): + https_retry_event.set() + + gui_task_queue.put((show_setup_message, queue.Queue())) + set_status_callback(None) - tray = TrayApp(worker, shutdown_event) + tray = TrayApp(worker, shutdown_event, https_retry_event) worker.start() server_thread = threading.Thread( target=run_server, - args=(worker, shutdown_event), + args=(worker, shutdown_event, https_retry_event, offer_https_retry), daemon=True, name="LoopbackServerManager", ) @@ -94,6 +107,7 @@ def main(): tray_thread.start() if protocol_command and protocol_command.get("action") == "scan": + def queue_protocol_scan(): try: worker.submit_job( diff --git a/mkcert.exe b/mkcert.exe deleted file mode 100644 index c9cde49..0000000 Binary files a/mkcert.exe and /dev/null differ diff --git a/requirements.txt b/requirements.txt index cc63b6f..d539090 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,3 +20,6 @@ requests>=2.31.0 # Build Tools pyinstaller>=6.18.0 + +# Per-user localhost identity generation and encrypted private keys +cryptography>=46.0.0 diff --git a/server.py b/server.py index a7bf417..1b51325 100644 --- a/server.py +++ b/server.py @@ -1,10 +1,23 @@ import io +import socket +import ssl import threading +import time from flask import Flask, Response, jsonify, request, send_file from werkzeug.serving import make_server -from utils import APP_ARCH, APP_VERSION, HTTP_HOST, HTTP_PORT, HTTPS_PORT, get_logger, get_ssl_context, load_config +from utils import ( + APP_ARCH, + APP_VERSION, + HTTP_HOST, + HTTP_PORT, + HTTPS_PORT, + get_logger, + get_ssl_context, + ensure_ssl_certificates, + load_config, +) from worker import BusyError logger = get_logger() @@ -63,6 +76,10 @@ def health(): "http_enabled": transport_state.get("http_enabled", False), "https_enabled": transport_state.get("https_enabled", False), "twain_available": worker.is_twain_available(), + "https_setup": transport_state.get( + "https_setup", + "ready" if transport_state.get("https_enabled") else "unavailable", + ), } ) @@ -72,15 +89,21 @@ def start_scan(): return empty_options_response() if not request.is_json: - return error_response(415, "json_required", "Scan requests must use an application/json body.") + return error_response( + 415, "json_required", "Scan requests must use an application/json body." + ) data = request.get_json(silent=True) if data is None: - return error_response(400, "invalid_json", "Request body could not be parsed as JSON.") + return error_response( + 400, "invalid_json", "Request body could not be parsed as JSON." + ) metadata = data.get("metadata") if metadata is not None and not isinstance(metadata, dict): - return error_response(400, "invalid_metadata", "metadata must be a JSON object when provided.") + return error_response( + 400, "invalid_metadata", "metadata must be a JSON object when provided." + ) doc_id = data.get("doc_id") or data.get("docId") callback_url = data.get("callback_url") or data.get("callback") @@ -109,7 +132,9 @@ def get_scan_status(job_id): job = worker.get_job(job_id) if not job: - return error_response(404, "job_not_found", "The requested scan job was not found.") + return error_response( + 404, "job_not_found", "The requested scan job was not found." + ) return jsonify(job) @app.route("/scan//result", methods=["GET", "OPTIONS"]) @@ -119,7 +144,9 @@ def get_scan_result(job_id): job = worker.get_job(job_id) if not job: - return error_response(404, "job_not_found", "The requested scan job was not found.") + return error_response( + 404, "job_not_found", "The requested scan job was not found." + ) if job["status"] != "completed": return error_response( 409, @@ -130,7 +157,9 @@ def get_scan_result(job_id): updated_job, result_bytes = worker.consume_result(job_id) if not updated_job: - return error_response(404, "job_not_found", "The requested scan job was not found.") + return error_response( + 404, "job_not_found", "The requested scan job was not found." + ) if not result_bytes: return error_response( 410, @@ -149,7 +178,17 @@ def get_scan_result(job_id): def internal_protocol_scan(): data = request.get_json(silent=True) or {} if data.get("action", "scan") != "scan": - return jsonify({"error": {"code": "unsupported_action", "message": "Only scan actions are supported."}}), 400 + return ( + jsonify( + { + "error": { + "code": "unsupported_action", + "message": "Only scan actions are supported.", + } + } + ), + 400, + ) try: job = worker.submit_job( @@ -201,8 +240,13 @@ def __init__(self, app, host, port, ssl_context=None, label="HTTP"): self.started = threading.Event() self.error = None + def _select_context(self, connection, server_name, initial_context): + connection.context = self.ssl_context + def run(self): try: + if self.ssl_context is not None: + self.ssl_context.sni_callback = self._select_context self.httpd = make_server( self.host, self.port, @@ -213,7 +257,13 @@ def run(self): logger.info("%s listener ready on %s:%s", self.label, self.host, self.port) except Exception as exc: self.error = exc - logger.error("Failed to start %s listener on %s:%s: %s", self.label, self.host, self.port, exc) + logger.error( + "Failed to start %s listener on %s:%s: %s", + self.label, + self.host, + self.port, + exc, + ) self.started.set() return @@ -230,54 +280,77 @@ def shutdown(self): self.httpd.shutdown() -def run_server(worker, shutdown_event): +def run_server(worker, shutdown_event, https_retry_event=None, on_https_error=None): + retry = https_retry_event or threading.Event() transport_state = { "http_enabled": False, "https_enabled": False, + "https_setup": "preparing", } app = create_app(worker, shutdown_event, transport_state) - listeners = [] - http_listener = LoopbackServer(app, HTTP_HOST, HTTP_PORT, label="HTTP") http_listener.start() http_listener.started.wait(timeout=5) - if not http_listener.error: + if not http_listener.error and http_listener.started.is_set(): transport_state["http_enabled"] = True listeners.append(http_listener) - ssl_context = get_ssl_context(log_errors=False) - if ssl_context: - https_listener = LoopbackServer( - app, - HTTP_HOST, - HTTPS_PORT, - ssl_context=ssl_context, - label="HTTPS", - ) - https_listener.start() - https_listener.started.wait(timeout=5) - if not https_listener.error: - transport_state["https_enabled"] = True - listeners.append(https_listener) - else: - logger.info("HTTPS listener disabled because no valid localhost certificates were found") - - if not listeners: - logger.error("No loopback listener could be started; stopping ScanLink") - shutdown_event.set() - return - - logger.info( - "Loopback server state: http_enabled=%s https_enabled=%s", - transport_state["http_enabled"], - transport_state["https_enabled"], - ) - - while not shutdown_event.wait(0.5): - pass - - for listener in listeners: - listener.shutdown() - for listener in listeners: - listener.join(timeout=5) + https_listener = None + next_check = 0 + report_failure = True + try: + while not shutdown_event.is_set(): + if retry.is_set(): + retry.clear() + next_check = 0 + report_failure = True + if time.monotonic() >= next_check: + try: + transport_state["https_setup"] = "preparing" + if not ensure_ssl_certificates(generate_if_missing=True): + raise RuntimeError("Certificate setup failed") + context = get_ssl_context() + if context is None: + raise RuntimeError("Certificate trust could not be verified") + if https_listener is None: + candidate = LoopbackServer( + app, + HTTP_HOST, + HTTPS_PORT, + ssl_context=context, + label="HTTPS", + ) + candidate.start() + candidate.started.wait(timeout=5) + if candidate.error or not candidate.started.is_set(): + raise RuntimeError("Secure connection could not be started") + https_listener = candidate + listeners.append(candidate) + else: + # New accepted connections use the renewed identity; active scans continue. + https_listener.ssl_context = context + with socket.create_connection( + (HTTP_HOST, HTTPS_PORT), timeout=3 + ) as connection: + with ssl.create_default_context().wrap_socket( + connection, server_hostname="localhost" + ): + pass + transport_state.update(https_enabled=True, https_setup="ready") + next_check = time.monotonic() + 3600 + except Exception: + logger.exception("Automatic HTTPS setup failed") + transport_state["https_setup"] = "failed" + next_check = time.monotonic() + 3600 + if report_failure and on_https_error: + on_https_error() + report_failure = False + if not listeners: + shutdown_event.set() + shutdown_event.wait(0.5) + finally: + for listener in listeners: + listener.shutdown() + for listener in listeners: + listener.join(timeout=5) diff --git a/static/scanlink.js b/static/scanlink.js index a32f190..4ff931d 100644 --- a/static/scanlink.js +++ b/static/scanlink.js @@ -12,6 +12,8 @@ class ScanLink { this.httpUrl = options.httpUrl || "http://localhost:5000"; this.httpsUrl = options.httpsUrl || "https://localhost:5443"; this.protocol = options.protocol || "scanlink"; + this.healthTimeoutMs = options.healthTimeoutMs || 3000; + this.connectionError = null; this.onStatus = options.onStatus || (() => {}); this.onPageScanned = options.onPageScanned || (() => {}); @@ -40,17 +42,11 @@ class ScanLink { for (const baseUrl of candidates) { try { - const response = await fetch(`${baseUrl}/health`, { - method: "GET", - mode: "cors", - }); - if (!response.ok) { - continue; - } - - const health = await response.json(); + const health = await this._healthAt(baseUrl); + if (!health) continue; this._setPreferredUrl(baseUrl); this.onConnectionChange(true); + this.connectionError = null; return health; } catch (error) { continue; @@ -58,9 +54,31 @@ class ScanLink { } this.onConnectionChange(false); + this.connectionError = this._connectionFailure(); return null; } + async _healthAt(baseUrl) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.healthTimeoutMs); + try { + const response = await fetch(`${baseUrl}/health`, { + method: "GET", mode: "cors", signal: controller.signal, + }); + // Read the body before clearing the timeout, including stalled responses. + return response.ok ? await response.json() : null; + } finally { + clearTimeout(timer); + } + } + + _connectionFailure(cause) { + if (typeof window !== "undefined" && window.isSecureContext === false) { + return new ScanLinkError("Open this application using its secure address, or contact your support team to enable scanning.", "insecure_origin", { cause }); + } + return new ScanLinkError("Start ScanLink on this computer and try again. If your browser asks to connect to this computer, choose Allow.", "helper_unavailable", { cause }); + } + async startScan(options = {}) { const payload = {}; if (options.docId) payload.doc_id = options.docId; @@ -213,11 +231,8 @@ class ScanLink { } this.onConnectionChange(false); - throw new ScanLinkError( - "ScanLink is not available on this computer.", - "helper_unavailable", - { cause: lastError } - ); + this.connectionError = this._connectionFailure(lastError); + throw this.connectionError; } _candidateUrls(force = false) { diff --git a/tests/test_certificates.py b/tests/test_certificates.py new file mode 100644 index 0000000..5030799 --- /dev/null +++ b/tests/test_certificates.py @@ -0,0 +1,176 @@ +import json +from pathlib import Path +import ssl +import socket +import threading +from urllib.request import urlopen +import unittest +from unittest.mock import patch +import uuid + +from cryptography import x509 +from cryptography.hazmat.primitives import serialization + +import certificates + + +class CertificateTests(unittest.TestCase): + def setUp(self): + self.directory = Path(".xclude/test-output/certificates") / uuid.uuid4().hex + self.identity = certificates.LocalIdentity(self.directory) + self.trusted = set() + self.secret_patch = patch( + "certificates._protect_secret", + side_effect=lambda value, decrypt=False: value, + ) + self.trust_patch = patch( + "certificates._is_trusted", side_effect=lambda der: der in self.trusted + ) + self.install_patch = patch( + "certificates._install_trust", side_effect=self.trusted.add + ) + for mock in (self.secret_patch, self.trust_patch, self.install_patch): + mock.start() + self.addCleanup(mock.stop) + + def active(self): + return json.loads(self.identity.pointer.read_text())["identity"] + + def test_first_run_creates_a_trusted_encrypted_localhost_leaf(self): + context = self.identity.ensure() + certificate, _ = self.identity._read() + self.assertIsInstance(context, ssl.SSLContext) + self.assertFalse( + certificate.extensions.get_extension_for_class( + x509.BasicConstraints + ).value.ca + ) + self.assertEqual( + len( + certificate.extensions.get_extension_for_class( + x509.SubjectAlternativeName + ).value + ), + 3, + ) + self.assertIn( + certificate.public_bytes(serialization.Encoding.DER), self.trusted + ) + bundle = (self.identity.root / self.active() / "localhost.pem").read_bytes() + self.assertIn(b"BEGIN ENCRYPTED PRIVATE KEY", bundle) + self.assertNotIn(b"BEGIN PRIVATE KEY", bundle) + self.identity.context() + + def test_healthy_launch_reuses_identity_without_reinstalling_trust(self): + self.identity.ensure() + previous = self.active() + with patch("certificates._install_trust") as install: + self.identity.ensure() + self.assertEqual(self.active(), previous) + install.assert_not_called() + + def test_missing_trust_is_repaired_without_replacing_identity(self): + self.identity.ensure() + previous = self.active() + self.trusted.clear() + with self.assertRaises(OSError): + self.identity.context() + self.identity.ensure() + self.assertEqual(self.active(), previous) + self.identity.context() + + def test_trust_failure_does_not_report_success_or_generate_a_new_key_on_retry(self): + with patch("certificates._install_trust", side_effect=OSError("policy denied")): + with self.assertRaisesRegex(OSError, "policy denied"): + self.identity.ensure() + pending = self.active() + with self.assertRaises(OSError): + self.identity.context() + self.identity.ensure() + self.assertEqual(self.active(), pending) + self.identity.context() + + def test_renewal_preserves_previous_credentials_under_xclude(self): + self.identity.ensure() + previous = self.active() + with patch("certificates.RENEW_BEFORE", certificates.timedelta(days=400)): + self.identity.ensure() + self.assertNotEqual(self.active(), previous) + self.assertTrue( + (self.directory / ".xclude/https" / previous / "localhost.pem").exists() + ) + self.identity.context() + + def test_corrupt_identity_is_replaced_without_losing_the_original(self): + self.identity.ensure() + previous = self.active() + bundle = self.identity.root / previous / "localhost.pem" + bundle.write_bytes(b"damaged") + self.identity.ensure() + self.assertNotEqual(self.active(), previous) + self.assertEqual( + ( + self.directory / ".xclude/https" / previous / "localhost.pem" + ).read_bytes(), + b"damaged", + ) + + def test_real_tls_handshake_and_live_identity_reload(self): + import server + from tests.test_server import FakeWorker + + context = self.identity.ensure() + certificate, _ = self.identity._read() + verifier = ssl.create_default_context( + cadata=certificate.public_bytes(serialization.Encoding.PEM).decode() + ) + app = server.create_app( + FakeWorker(), threading.Event(), {"https_enabled": True} + ) + listener = server.LoopbackServer(app, "127.0.0.1", 0, ssl_context=context) + listener.start() + self.assertTrue(listener.started.wait(5)) + self.assertIsNone(listener.error) + port = listener.httpd.server_port + try: + with urlopen( + f"https://127.0.0.1:{port}/health", context=verifier, timeout=3 + ) as response: + self.assertEqual(json.load(response)["status"], "ok") + with socket.create_connection(("127.0.0.1", port), timeout=3) as connection: + with self.assertRaises(ssl.SSLCertVerificationError): + verifier.wrap_socket( + connection, server_hostname="another-host.invalid" + ) + with patch("certificates.RENEW_BEFORE", certificates.timedelta(days=400)): + renewed = self.identity.ensure() + listener.ssl_context = renewed + certificate, _ = self.identity._read() + verifier = ssl.create_default_context( + cadata=certificate.public_bytes(serialization.Encoding.PEM).decode() + ) + for hostname in ("localhost", "127.0.0.1"): + with urlopen( + f"https://{hostname}:{port}/health", context=verifier, timeout=3 + ) as response: + self.assertEqual(json.load(response)["status"], "ok") + finally: + listener.shutdown() + listener.join(5) + + def test_pointer_cannot_escape_the_identity_directory(self): + self.identity.root.mkdir(parents=True) + self.identity.pointer.write_text(json.dumps({"identity": "../outside"})) + self.identity.ensure() + self.assertNotEqual(self.active(), "../outside") + + +@unittest.skipUnless(certificates.os.name == "nt", "Windows DPAPI integration") +class WindowsSecretTests(unittest.TestCase): + def test_private_key_password_is_bound_to_the_windows_user(self): + password = b"ScanLink-test-" + uuid.uuid4().bytes + protected = certificates._protect_secret(password) + self.assertNotIn(password, protected) + self.assertEqual( + certificates._protect_secret(protected, decrypt=True), password + ) diff --git a/tests/test_server.py b/tests/test_server.py index 2260ea6..6586b18 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1,6 +1,8 @@ import threading +import json +from urllib.request import urlopen import unittest -from unittest.mock import patch +from unittest.mock import MagicMock, patch from worker import BusyError @@ -50,11 +52,7 @@ def get_job(self, job_id): if not job: return None - return { - key: value - for key, value in job.items() - if key != "result_bytes" - } + return {key: value for key, value in job.items() if key != "result_bytes"} def consume_result(self, job_id): job = self.jobs.get(job_id) @@ -72,9 +70,13 @@ def setUp(self): self.worker = FakeWorker() self.transport_state = {"http_enabled": True, "https_enabled": False} self.shutdown_event = threading.Event() - self.config_patch = patch("server.load_config", return_value={"cors_origins": ["*"]}) + self.config_patch = patch( + "server.load_config", return_value={"cors_origins": ["*"]} + ) self.config_patch.start() - self.app = server.create_app(self.worker, self.shutdown_event, self.transport_state) + self.app = server.create_app( + self.worker, self.shutdown_event, self.transport_state + ) self.client = self.app.test_client() def tearDown(self): @@ -93,6 +95,78 @@ def test_health_reports_transport_flags(self): self.assertFalse(payload["busy"]) self.assertTrue(payload["twain_available"]) + def test_certificate_setup_failure_keeps_http_available_and_reports_failure(self): + failed = threading.Event() + listeners = [] + original_listener = server.LoopbackServer + + def create_listener(app, host, port, **options): + listener = original_listener(app, host, 0, **options) + listeners.append(listener) + return listener + + with patch("server.LoopbackServer", side_effect=create_listener), patch( + "server.ensure_ssl_certificates", return_value=False + ): + manager = threading.Thread( + target=server.run_server, + args=(self.worker, self.shutdown_event, threading.Event(), failed.set), + ) + manager.start() + try: + self.assertTrue(failed.wait(5)) + port = listeners[0].httpd.server_port + with urlopen(f"http://127.0.0.1:{port}/health", timeout=3) as response: + health = json.load(response) + self.assertTrue(health["http_enabled"]) + self.assertFalse(health["https_enabled"]) + self.assertEqual(health["https_setup"], "failed") + self.assertTrue(manager.is_alive()) + finally: + self.shutdown_event.set() + manager.join(5) + self.assertFalse(manager.is_alive()) + + def test_retry_enables_https_after_a_failed_first_attempt(self): + retry = threading.Event() + observations = [] + listeners = [] + shutdown = threading.Event() + + def listener_factory(app, *args, **kwargs): + listener = MagicMock() + listener.app = app + listener.error = None + listener.started.is_set.return_value = True + listeners.append(listener) + return listener + + def next_cycle(timeout): + health = listeners[0].app.test_client().get("/health").get_json() + observations.append(health["https_setup"]) + if len(observations) == 1: + self.assertTrue(health["http_enabled"]) + self.assertFalse(health["https_enabled"]) + retry.set() + else: + self.assertTrue(health["https_enabled"]) + shutdown.set() + + with patch("server.LoopbackServer", side_effect=listener_factory), patch( + "server.ensure_ssl_certificates", side_effect=[False, True] + ), patch("server.get_ssl_context", return_value=MagicMock()), patch( + "server.socket.create_connection" + ), patch( + "server.ssl.create_default_context" + ), patch.object( + shutdown, "wait", side_effect=next_cycle + ): + server.run_server(self.worker, shutdown, retry) + self.assertEqual(observations, ["failed", "ready"]) + self.assertEqual(len(listeners), 2) + for listener in listeners: + listener.shutdown.assert_called_once() + def test_scan_request_maps_browser_payload_to_job(self): response = self.client.post( "/scan", @@ -148,7 +222,9 @@ def test_scan_endpoint_reflects_origin(self): ) self.assertEqual(response.status_code, 202) - self.assertEqual(response.headers["Access-Control-Allow-Origin"], "http://localhost:82") + self.assertEqual( + response.headers["Access-Control-Allow-Origin"], "http://localhost:82" + ) def test_result_endpoint_returns_pdf(self): self.worker.jobs["job-123"] = { @@ -200,7 +276,9 @@ def test_completed_result_can_only_be_downloaded_once(self): self.assertEqual(first_response.status_code, 200) self.assertEqual(second_response.status_code, 410) - self.assertEqual(second_response.get_json()["error"]["code"], "result_unavailable") + self.assertEqual( + second_response.get_json()["error"]["code"], "result_unavailable" + ) def test_protocol_endpoint_queues_protocol_job(self): response = self.client.post( diff --git a/tests/windows_install_smoke.ps1 b/tests/windows_install_smoke.ps1 index 758b954..9d86e54 100644 --- a/tests/windows_install_smoke.ps1 +++ b/tests/windows_install_smoke.ps1 @@ -25,6 +25,47 @@ $logPath = Join-Path $env:LOCALAPPDATA "ScanLink\scanner.log" $healthUri = "http://127.0.0.1:5000/health" $appProcess = $null +# Exercise the real first-launch consent rather than bypassing Windows trust. +Add-Type -TypeDefinition @' +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +public static class ScanLinkConsent { + private delegate bool EnumWindow(IntPtr window, IntPtr parameter); + [DllImport("user32.dll")] private static extern bool EnumWindows(EnumWindow callback, IntPtr parameter); + [DllImport("user32.dll")] private static extern bool EnumChildWindows(IntPtr parent, EnumWindow callback, IntPtr parameter); + [DllImport("user32.dll", CharSet=CharSet.Unicode)] private static extern int GetWindowText(IntPtr window, StringBuilder text, int maximum); + [DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId); + [DllImport("user32.dll")] private static extern IntPtr GetDlgItem(IntPtr window, int identifier); + [DllImport("user32.dll")] private static extern IntPtr SendMessage(IntPtr window, uint message, IntPtr wParam, IntPtr lParam); + public static bool Approve(string executable) { + bool approved = false; + EnumWindows((window, parameter) => { + uint processId; + GetWindowThreadProcessId(window, out processId); + try { + if (!String.Equals(Process.GetProcessById((int)processId).MainModule.FileName, executable, StringComparison.OrdinalIgnoreCase)) return true; + } catch { return true; } + var text = new StringBuilder(); + EnumChildWindows(window, (child, unused) => { + var value = new StringBuilder(4096); + GetWindowText(child, value, value.Capacity); + text.AppendLine(value.ToString()); + return true; + }, IntPtr.Zero); + if (!text.ToString().Contains("ScanLink localhost")) return true; + IntPtr yes = GetDlgItem(window, 6); // IDYES, independent of UI language. + if (yes == IntPtr.Zero) return true; + SendMessage(yes, 0x00F5, IntPtr.Zero, IntPtr.Zero); // BM_CLICK + approved = true; + return false; + }, IntPtr.Zero); + return approved; + } +} +'@ + $portInUse = $false try { $null = Invoke-RestMethod -Uri $healthUri -TimeoutSec 2 @@ -110,6 +151,30 @@ try { if (!$health.http_enabled) { throw "Installed ScanLink did not enable its HTTP listener." } + $secureDeadline = [DateTime]::UtcNow.AddSeconds(45) + while (!$health.https_enabled -and [DateTime]::UtcNow -lt $secureDeadline) { + if ([ScanLinkConsent]::Approve($appExe)) { + Write-Host "Approved the Windows certificate prompt for ScanLink localhost." + } + if ($health.https_setup -eq "failed") { throw "Automatic HTTPS setup failed." } + Start-Sleep -Milliseconds 500 + $health = Invoke-RestMethod -Uri $healthUri -TimeoutSec 3 + } + if (!$health.https_enabled -or $health.https_setup -ne "ready") { + throw "Automatic HTTPS setup did not finish." + } + # Use normal Windows TLS verification: no trust bypass or certificate CLI. + $secureHealth = Invoke-RestMethod -Uri "https://localhost:5443/health" -TimeoutSec 5 + if ($secureHealth.version -ne $version) { throw "Trusted HTTPS returned the wrong version." } + $stressResults = @(1..120 | ForEach-Object -Parallel { + $response = Invoke-RestMethod -Uri "https://localhost:5443/health" -TimeoutSec 10 + if ($response.status -ne "ok" -or !$response.https_enabled) { throw "HTTPS stress request failed." } + $true + } -ThrottleLimit 12) + if ($stressResults.Count -ne 120) { throw "HTTPS stress did not complete every request." } + Write-Host "120 concurrent-batch HTTPS requests passed with normal Windows certificate verification." + $identityPath = Join-Path $env:LOCALAPPDATA "ScanLink\https\active.json" + $firstIdentity = (Get-Content $identityPath -Raw | ConvertFrom-Json).identity if ($health.busy) { throw "Freshly installed ScanLink unexpectedly reports itself busy." } @@ -147,6 +212,22 @@ try { if (!$appProcess.WaitForExit(15000)) { throw "Installed ScanLink did not exit cleanly after shutdown." } + $appProcess = Start-Process -FilePath $appExe -PassThru + $restartDeadline = [DateTime]::UtcNow.AddSeconds($StartupTimeoutSeconds) + $restarted = $false + while ([DateTime]::UtcNow -lt $restartDeadline) { + try { + $secureHealth = Invoke-RestMethod -Uri "https://localhost:5443/health" -TimeoutSec 3 + if ($secureHealth.https_setup -eq "ready") { $restarted = $true; break } + } catch {} + Start-Sleep -Milliseconds 500 + } + if (!$restarted) { throw "Trusted HTTPS did not recover after restart." } + $secondIdentity = (Get-Content $identityPath -Raw | ConvertFrom-Json).identity + if ($firstIdentity -ne $secondIdentity) { throw "Restart unnecessarily replaced the HTTPS identity." } + $null = Invoke-RestMethod -Uri "http://127.0.0.1:5000/shutdown" -Method Post -TimeoutSec 3 + if (!$appProcess.WaitForExit(15000)) { throw "Restarted ScanLink did not exit cleanly." } + } catch { if (Test-Path $logPath) { Write-Host "---- ScanLink log ----" diff --git a/tracker.md b/tracker.md index e0eb457..ecc40fd 100644 --- a/tracker.md +++ b/tracker.md @@ -1,75 +1,44 @@ -# Project Tracker (app-ScanLink) [Max 100 lines total] - -## Part 1: Project Related [Max 55 lines] -### Current Verified Snapshot: [Max 5 lines] -- Workspace: `/Users/debeski/Desktop/depy/app-scanlink/main` (moved 2026-10-21 into the per-branch checkout layout; branches `/`, rules in `AGENTS.md`). -- Python Windows tray app packaged with PyInstaller/Inno Setup; TWAIN scan jobs run via `worker.py` -> child `main.py --scan-worker-dir` -> `scan_runtime.py`. -- Scanner integration is `scanner.py` using `pytwain`/`twain.SourceManager`; generated PDFs are built with ReportLab from Pillow images. -- Current version file: `0.7.4`; latest local git tag seen: `v0.7.3`. - -### Current Project Adopted Standards: [Max 5 lines] -- TWAIN scanning through `pytwain`; executable architecture follows the Python/PyInstaller interpreter architecture. -- Per-user config is `%LOCALAPPDATA%\ScanLink\config.json`; logs are `%LOCALAPPDATA%\ScanLink\scanner.log`. -- Releases publish by version tags; check `git tag` before editing released changelog entries. - -### Adopted Standards' rules and policies: [Max 5 lines] -- Re-read and maintain this tracker at the start of every turn and after meaningful state changes. -- Never delete files/directories; move obsolete paths under `./.xpose/` preserving relative paths. -- Update root `CHANGELOG.md` immediately after implemented features/fixes/config fixes; update docs when code changes alter behavior/config/API/deployment. - -### Cross-Cutting Audits if any: [Max 3 lines] -- Packaging audit: `build_exe.py` supports x86/x64 orchestration, but each build still requires matching-bitness Python. -- Windows gate: both CI and tag releases install and smoke-test each packaged architecture before accepting its installer artifact. -- Scanner profile audit: v0.7.2 owns TWAIN caps for ADF/flatbed, simplex/duplex, color mode, DPI, and paper protection; it still does not create/select named PaperStream profiles. - -### Current Project's Unsolved Known Bugs: [Max 5 lines] -- Needs live Windows/TWAIN hardware verification: v0.7.4 requests ADF/front/200/color/paper-off, but driver compliance and PDF output quality cannot be exercised on hosted runners. -- Back-only side mode and blank-page discard remain unsupported; standard tray side modes are `front` and `front_back`. -- Prior field issue: fi-6230Z may expose only 32-bit TWAIN source; target PC reportedly lacked `C:\Windows\twain_64`. -- Archive-side context: `../project-archive/documents/forms.py` validates PDFs at 40 MB max. - -### Incomplete Tasks: [Max 20 lines] -- **Priority 1:** - - [ ] Live-test v0.7.4 on Windows with target PaperStream IP scanners and verify logs show accepted `CAP_DUPLEXENABLED=False`, ADF, 200 DPI, and color. - - [ ] Confirm the v0.7.4 x64/x86 GitHub Windows installer-smoke matrix passes after push. -- **Priority 2:** - - [ ] Design Archive-hosted ScanLink update path: server manifest/artifacts plus client localhost version check or ScanLink self-update endpoint. - - [ ] Add diagnostics logging of supported key TWAIN capabilities for selected source. - - [ ] Re-test Windows scanner behavior with PaperStream IP after changes. - - [ ] Test x86 build on fi-6230Z if only `twain_32` source exists. -- **Completed Recently:** - - [x] Added x86/x64 installed-app smoke gates plus critical REST/protocol/shutdown unit coverage for v0.7.4. - - [x] Made `tests/` importable and gated release x86/x64 builds on the exact Windows unit-test job. - - [x] Added v0.7.2 archive-safe defaults/migration: ADF, front-only, 200 DPI, color, paper protection off. - - [x] Added tray controls for Source, Side, Color, Resolution, and Paper Protection. - - [x] Applied TWAIN capability requests for feeder/autofeed, duplex enable, pixel type, optional bit depth, paper protection, indicators, and transfer count. - - [x] Updated `VERSION`, `CHANGELOG.md`, `README.md`, `docs/architecture.md`, `docs/developer-guide.md`, and `docs/api.md`. - - [x] Investigated ScanLink profile/settings behavior after archive file-size report on 2026-07-08. - -### One-line info about last verified Tests: [Max 5 lines] -- 2026-08-02: Exact discovery passed 25 tests with Flask 3.0.0; CI/release YAML parsed and both build matrices invoke `tests/windows_install_smoke.ps1` after installer compilation. -- 2026-08-02: Exact CI command `python3 -m unittest discover -s tests -t . -v` passed all 16 tests with Flask 3.0.0; release YAML parsed and `build.needs == test` was verified. -- 2026-07-08: `python3 -m unittest discover -s tests` passed 16 tests, 5 skipped; `python3 -m compileall scanner.py tray.py utils.py tests/test_scanner_profile.py` passed. -- 2026-07-08: plain `python3 -m unittest discover` found 0 tests due repo layout; use `-s tests`. -- 2026-07-08 limitation: local environment lacks `twain` and Windows scanner hardware, so live PaperStream verification was not run. -- Prior checks: `python -m compileall build_exe.py`, `python build_exe.py --help`, and build dry-runs passed on 2026-04-29; Inno compile not available in local environment. - -### One-line info about last time edited Docs: [Max 2 lines] -- 2026-08-02: Updated `docs/building.md` with automated installed-app checks and the remaining physical Windows/TWAIN boundary; changelog entry `v0.7.4`. -- 2026-08-02: Updated `docs/building.md` with exact discovery command and mandatory tag-release test gate; changelog entry `v0.7.3`. - -## Part 2: Global [Max 20 lines] -### Global Standard Helpers, Shortcuts, Info, etc.: -- Use `rg`/`rg --files` first for repository discovery. -- TWAIN paths on 64-bit Windows: 64-bit DSM `C:\Windows\System32\TWAINDSM.DLL`; 32-bit DSM `C:\Windows\SysWOW64\TWAINDSM.DLL`; data sources usually `C:\Windows\twain_32`/`twain_64`. - -### Global Rulesets: -- Keep tracker facts grounded in verified code, verified runtime behavior, or explicit user correction. -- Do not mix reusable/global guidance into project-specific sections. - -### Agent Handoff Rules: -- Re-read `tracker.md` at the start of every turn. -- Update `tracker.md` after meaningful changes to bugs, tasks, tests, docs, standards interpretation, or handoff guidance. - -### References and Links: -- Related archive project: `/Users/debeski/Desktop/depy/project-archive`. +# ScanLink tracker + +## Part 1: Project-specific +### Current Snapshot +- Patch candidate 0.7.5b1 on `0.7/automatic_https`, checkout `app-scanlink/automatic_https`; base main 4bf0c05. Latest verified tag v0.7.4. Owner authorized beta publication and testbed stress acceptance on 2026-10-25; PR/Windows gates pending. +- Windows tray app uses TWAIN/pytwain; scan jobs run through child `main.py --scan-worker-dir` / `scan_runtime.py`. +- Normal startup serves HTTP :5000 while automatically provisioning trusted localhost HTTPS :5443; failed setup offers native Retry and a tray repair action. +- `certificates.py`: per-user localhost-only self-signed server identity, Windows CurrentUser Root trust, encrypted PKCS#8 key with DPAPI-protected password; hourly checks/30-day renewal and live TLS context selection. +- Paired with DjangoLux 1.15.1b1 (`1.15/scanlink_https`); REST remains compatible with older clients. New optional health field `https_setup` = preparing/ready/failed. + +### Adopted Standards & Rules +- One patch/workstream per release-line branch; VERSION is the version source. Do not tag or publish without owner approval. +- App data/config/logs live under `%LOCALAPPDATA%\ScanLink`; identity pointer is `https/active.json`, immutable bundles `https//`, superseded identities `.xclude/https//`. +- Certificate covers only localhost/127.0.0.1/::1, CA=False/serverAuth/no certificate-signing usage; no downloaded/shared CA or machine-wide trust. Existing mkcert credentials stay untouched. +- HTTP remains supported; browser local-network permission, CSP, enterprise trust and an insecure application origin are independent restrictions. +- Never delete workspace files; relocate under `.xclude/` preserving relative paths. Match executable architecture to the TWAIN driver. + +### Known Bugs & Unsolved Issues +- Windows CryptoAPI/DPAPI, x86/x64 packaged startup and browser trust require real Windows acceptance; local host is macOS. CI installed-app smoke now requires normal verified HTTPS and identity reuse after restart. +- Target PaperStream fi-6230Z may expose only 32-bit TWAIN; live driver compliance/output quality remain unverified. Back-only and blank-page discard remain unsupported. +- Organization/browser policies may refuse user-installed trust. A denied setup is reported as failure and retried with the same identity, without repeated automatic dialogs. + +### Active & Pending Tasks +- [ ] Run x86/x64 Windows installer smoke for 0.7.5b1; verify first launch, CurrentUser-only trust, DPAPI key, restart, policy denial and Retry with the real DjangoLux client. +- [ ] Verify 0.7.5b1 with target scanner hardware; preserve ADF/front/200 DPI/color/paper protection off defaults. +- [ ] Rebase/integrate the paired DjangoLux patch around the open 1.15 release; owner approval required for publication. +- [ ] Archive-hosted helper update path and detailed TWAIN capability diagnostics remain separate work. +### Completed Recently +- [x] Automatic setup/repair/renewal and plain native recovery; removed optional mkcert packaging requirement and retained `--generate-ssl` for support compatibility. +- [x] Extended installer smoke to normal Windows-trusted HTTPS and restart reuse; health timeout/fallback behavior matches the paired browser client. +- [x] Verified real TLS handshakes, hostname rejection and live identity renewal; setup failure preserves HTTP and Retry recovers HTTPS. + +### Validation & Docs Log +- 2026-10-25: exact unittest discovery 36 tests OK/1 Windows DPAPI skip; paired JS 12 scenarios; Chrome155 HTTPS/local-HTTP fallback and insecure-LAN guidance pass; Win32 cryptography wheel resolves. Windows installer build/smoke pending (Mac build wrapper cannot use Windows Scripts/python.exe). +- 2026-10-25: README, architecture, API, developer/build guides and CHANGELOG v0.7.5b1 describe automatic setup, protected identities, renewal, retry and compatibility. + +## Part 2: Global & Handoff +### Global Helpers & Rules +- Use `rg` first. Unit wrapper: `python -m unittest discover -s tests -t . -v`; installer gate: `tests/windows_install_smoke.ps1` on Windows. +- Test stacks belong under `~/Desktop/depy/testbed-*`, use Composer `--dev` and application admin/db123123; preserve testbed-dlux. +### Agent Handoff +- Both patch checkouts are authorized by the owner from the app-mock-pdf chat; preserve that checkout's unrelated uncommitted changes. +- PR1 Windows binaries built; initial installed-app gates stalled at first-launch trust. Added scoped native Yes-prompt handling and 120 verified HTTPS stress requests; rerun pending. Do not claim native/browser trust was verified on this Mac. Beta publication and testbed stress testing authorized on 2026-10-25; preserve stable releases and existing testbed data. +- Evidence: `.xclude/validation/`; paired Chrome evidence is in `pkg-django-lux/scanlink_https/.xclude/validation/chrome-loopback.log`. diff --git a/tray.py b/tray.py index b34317b..df1b31d 100644 --- a/tray.py +++ b/tray.py @@ -96,10 +96,11 @@ def format_color_mode(value): class TrayApp: - def __init__(self, worker, shutdown_event): + def __init__(self, worker, shutdown_event, https_retry_event=None): self.worker = worker self.shutdown_event = shutdown_event self.icon = None + self.https_retry_event = https_retry_event or threading.Event() def create_image(self): icon_path = get_resource_path("app_icon.ico") @@ -282,11 +283,12 @@ def on_startup_toggle(icon, item): enabled=False, ), MenuItem( - lambda item: "HTTPS: Enabled" if is_ssl_available() else "HTTPS: Disabled", + lambda item: "Secure connection: configured" if is_ssl_available() else "Secure connection: setup needed", None, enabled=False, ), Menu.SEPARATOR, + MenuItem("Retry secure connection setup", lambda icon, item: self.https_retry_event.set()), MenuItem("Test Scan", on_test_scan, enabled=lambda item: not self.worker.is_busy()), MenuItem( lambda item: f"Source: {format_scan_source(load_config().get('scan_source'))}", diff --git a/utils.py b/utils.py index 27df886..23ffd3a 100644 --- a/utils.py +++ b/utils.py @@ -200,85 +200,37 @@ def get_resource_path(relative_path): # ============================================================================ # SSL/HTTPS Certificate Management # ============================================================================ -SSL_CERT_FILE = os.path.join(APP_DIR, 'localhost.pem') -SSL_KEY_FILE = os.path.join(APP_DIR, 'localhost-key.pem') -MKCERT_EXE = 'mkcert.exe' - def get_ssl_context(log_errors=True): - """ - Returns SSL context if certificates exist, otherwise None. - """ - if os.path.exists(SSL_CERT_FILE) and os.path.exists(SSL_KEY_FILE): - try: - import ssl - ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) - ctx.load_cert_chain(SSL_CERT_FILE, SSL_KEY_FILE) - return ctx - except Exception as e: - if log_errors: - get_logger().error(f"Failed to load SSL context: {e}") - return None - return None + from certificates import LocalIdentity + + try: + return LocalIdentity(APP_DIR).context() + except Exception: + if log_errors: + get_logger().exception("ScanLink HTTPS identity is unavailable") + return None + def is_ssl_available(): - """Check if SSL certificates are available.""" return get_ssl_context(log_errors=False) is not None -def generate_ssl_certificates(): - """ - Generates localhost SSL certificates using bundled mkcert. - Returns True on success, False on failure. - """ - import subprocess - - logger = get_logger() - mkcert_path = get_resource_path(MKCERT_EXE) - - if not os.path.exists(mkcert_path): - logger.error(f"mkcert not found at {mkcert_path}") - return False - + +def ensure_ssl_certificates(generate_if_missing=False): + from certificates import LocalIdentity + try: - # Install local CA (one-time, requires admin on first run) - result = subprocess.run( - [mkcert_path, '-install'], - capture_output=True, text=True, timeout=30 - ) - if result.returncode != 0: - logger.warning(f"mkcert -install warning: {result.stderr}") + if generate_if_missing: + LocalIdentity(APP_DIR).ensure() else: - logger.info("mkcert CA installed successfully") - - # Generate certificates for localhost - result = subprocess.run( - [mkcert_path, '-cert-file', SSL_CERT_FILE, '-key-file', SSL_KEY_FILE, - 'localhost', '127.0.0.1', '::1'], - capture_output=True, text=True, timeout=30 - ) - if result.returncode != 0: - logger.error(f"mkcert certificate generation failed: {result.stderr}") - return False - - logger.info(f"SSL certificates generated: {SSL_CERT_FILE}") + LocalIdentity(APP_DIR).context() return True - - except subprocess.TimeoutExpired: - logger.error("mkcert timed out") - return False - except Exception as e: - logger.error(f"SSL certificate generation failed: {e}") + except Exception: + get_logger().exception("ScanLink could not prepare its trusted HTTPS connection") return False -def ensure_ssl_certificates(generate_if_missing=False): - """ - Ensures SSL certificates exist and optionally generates them. - Returns True if certificates are available. - """ - if is_ssl_available(): - return True - if not generate_if_missing: - return False - return generate_ssl_certificates() + +def generate_ssl_certificates(): + return ensure_ssl_certificates(generate_if_missing=True) # ============================================================================