Skip to content

Commit b15c629

Browse files
authored
Fix root certificate issues for add-on store (#18354)
Summary of the issue: If a device doesn't trust NV Access's TLS certificate, fetches to the add-on store fails. When performing an update check, we update windows root certificates if our certificate is invalid or out of date. Description of user facing changes: Users without a trusted NV Access TLS certificate installed already should be able to access the add-on store Description of developer facing changes: Code related to updating root certificates is deprecated as it is not intended for the public Description of development approach: Made the code to update certificates more generic
1 parent 4f8927a commit b15c629

File tree

4 files changed

+162
-93
lines changed

4 files changed

+162
-93
lines changed

source/addonStore/dataManager.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from logHandler import log
2828
import NVDAState
2929
from NVDAState import WritePaths
30+
from utils.networking import _fetchUrlAndUpdateRootCertificates
3031

3132
from .models.addon import (
3233
AddonStoreModel,
@@ -135,11 +136,11 @@ def _getLatestAddonsDataForVersion(self, apiVersion: str) -> Optional[bytes]:
135136
return None
136137
return response.content
137138

138-
def _getCacheHash(self) -> Optional[str]:
139+
def _getCacheHash(self) -> str | None:
139140
url = _getCacheHashURL()
140141
try:
141142
log.debug(f"Fetching add-on data from {url}")
142-
response = requests.get(url, timeout=FETCH_TIMEOUT_S)
143+
response = _fetchUrlAndUpdateRootCertificates(url)
143144
except requests.exceptions.RequestException as e:
144145
log.debugWarning(f"Unable to get cache hash: {e}")
145146
return None

source/updateCheck.py

Lines changed: 41 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,6 @@
4848
import urllib.request
4949
import urllib.parse
5050
import hashlib
51-
import ctypes.wintypes
52-
import requests
53-
import ssl
5451
import wx
5552
import languageHandler
5653

@@ -68,9 +65,41 @@
6865
import addonAPIVersion
6966
from logHandler import log, isPathExternalToNVDA
7067
import winKernel
68+
from utils.networking import _fetchUrlAndUpdateRootCertificates
7169
from utils.tempFile import _createEmptyTempFileForDeletingFile
7270
from dataclasses import dataclass
7371

72+
import NVDAState
73+
74+
75+
def __getattr__(attrName: str) -> Any:
76+
"""Module level `__getattr__` used to preserve backward compatibility."""
77+
if attrName == "CERT_USAGE_MATCH" and NVDAState._allowDeprecatedAPI():
78+
log.warning(
79+
"CERT_USAGE_MATCH is deprecated and will be removed in a future version of NVDA. ",
80+
stack_info=True,
81+
)
82+
from utils.networking import _CERT_USAGE_MATCH as CERT_USAGE_MATCH
83+
84+
return CERT_USAGE_MATCH
85+
if attrName == "CERT_CHAIN_PARA" and NVDAState._allowDeprecatedAPI():
86+
log.warning(
87+
"CERT_CHAIN_PARA is deprecated and will be removed in a future version of NVDA. ",
88+
stack_info=True,
89+
)
90+
from utils.networking import _CERT_CHAIN_PARA as CERT_CHAIN_PARA
91+
92+
return CERT_CHAIN_PARA
93+
if attrName == "UPDATE_FETCH_TIMEOUT_S" and NVDAState._allowDeprecatedAPI():
94+
log.warning(
95+
"UPDATE_FETCH_TIMEOUT_S is deprecated and will be removed in a future version of NVDA. ",
96+
stack_info=True,
97+
)
98+
from utils.networking import _FETCH_TIMEOUT_S as UPDATE_FETCH_TIMEOUT_S
99+
100+
return UPDATE_FETCH_TIMEOUT_S
101+
raise AttributeError(f"module {repr(__name__)} has no attribute {repr(attrName)}")
102+
74103

75104
#: The URL to use for update checks.
76105
_DEFAULT_CHECK_URL = "https://api.nvaccess.org/nvdaUpdateCheck"
@@ -176,9 +205,6 @@ def getQualifiedDriverClassNameForStats(cls):
176205
return "%s (core)" % name
177206

178207

179-
UPDATE_FETCH_TIMEOUT_S = 30 # seconds
180-
181-
182208
def checkForUpdate(auto: bool = False) -> UpdateInfo | None:
183209
"""Check for an updated version of NVDA.
184210
This will block, so it generally shouldn't be called from the main thread.
@@ -230,28 +256,17 @@ def checkForUpdate(auto: bool = False) -> UpdateInfo | None:
230256
}
231257
params.update(extraParams)
232258

233-
url = f"{_getCheckURL()}?{urllib.parse.urlencode(params)}"
234-
try:
235-
log.debug(f"Fetching update data from {url}")
236-
res = urllib.request.urlopen(url, timeout=UPDATE_FETCH_TIMEOUT_S)
237-
except IOError as e:
238-
if (
239-
isinstance(e.reason, ssl.SSLCertVerificationError)
240-
and e.reason.reason == "CERTIFICATE_VERIFY_FAILED"
241-
):
242-
# #4803: Windows fetches trusted root certificates on demand.
243-
# Python doesn't trigger this fetch (PythonIssue:20916), so try it ourselves
244-
_updateWindowsRootCertificates()
245-
# Retry the update check
246-
log.debug(f"Retrying update check from {url}")
247-
res = urllib.request.urlopen(url, timeout=UPDATE_FETCH_TIMEOUT_S)
248-
else:
249-
raise
259+
result = _fetchUrlAndUpdateRootCertificates(
260+
url=f"{_getCheckURL()}?{urllib.parse.urlencode(params)}",
261+
# We must specify versionType so the server doesn't return a 404 error and
262+
# thus cause an exception.
263+
certFetchUrl=f"{_getCheckURL()}?versionType=stable",
264+
)
250265

251-
if res.code != 200:
252-
raise RuntimeError(f"Checking for update failed with HTTP status code {res.code}.")
266+
if result.status_code != 200:
267+
raise RuntimeError(f"Checking for update failed with HTTP status code {result.status_code}.")
253268

254-
data = res.read().decode("utf-8") # Ensure the response is decoded correctly
269+
data = result.content.decode("utf-8") # Ensure the response is decoded correctly
255270
# if data is empty, we return None, because the server returns an empty response if there is no update.
256271
if not data:
257272
return None
@@ -1038,68 +1053,3 @@ def terminate():
10381053
if autoChecker:
10391054
autoChecker.terminate()
10401055
autoChecker = None
1041-
1042-
1043-
# These structs are only complete enough to achieve what we need.
1044-
class CERT_USAGE_MATCH(ctypes.Structure):
1045-
_fields_ = (
1046-
("dwType", ctypes.wintypes.DWORD),
1047-
# CERT_ENHKEY_USAGE struct
1048-
("cUsageIdentifier", ctypes.wintypes.DWORD),
1049-
("rgpszUsageIdentifier", ctypes.c_void_p), # LPSTR *
1050-
)
1051-
1052-
1053-
class CERT_CHAIN_PARA(ctypes.Structure):
1054-
_fields_ = (
1055-
("cbSize", ctypes.wintypes.DWORD),
1056-
("RequestedUsage", CERT_USAGE_MATCH),
1057-
("RequestedIssuancePolicy", CERT_USAGE_MATCH),
1058-
("dwUrlRetrievalTimeout", ctypes.wintypes.DWORD),
1059-
("fCheckRevocationFreshnessTime", ctypes.wintypes.BOOL),
1060-
("dwRevocationFreshnessTime", ctypes.wintypes.DWORD),
1061-
("pftCacheResync", ctypes.c_void_p), # LPFILETIME
1062-
("pStrongSignPara", ctypes.c_void_p), # PCCERT_STRONG_SIGN_PARA
1063-
("dwStrongSignFlags", ctypes.wintypes.DWORD),
1064-
)
1065-
1066-
1067-
def _updateWindowsRootCertificates():
1068-
log.debug("Updating Windows root certificates")
1069-
crypt = ctypes.windll.crypt32
1070-
with requests.get(
1071-
# We must specify versionType so the server doesn't return a 404 error and
1072-
# thus cause an exception.
1073-
f"{_getCheckURL()}?versionType=stable",
1074-
timeout=UPDATE_FETCH_TIMEOUT_S,
1075-
# Use an unverified connection to avoid a certificate error.
1076-
verify=False,
1077-
stream=True,
1078-
) as response:
1079-
# Get the server certificate.
1080-
cert = response.raw.connection.sock.getpeercert(True)
1081-
# Convert to a form usable by Windows.
1082-
certCont = crypt.CertCreateCertificateContext(
1083-
0x00000001, # X509_ASN_ENCODING
1084-
cert,
1085-
len(cert),
1086-
)
1087-
# Ask Windows to build a certificate chain, thus triggering a root certificate update.
1088-
chainCont = ctypes.c_void_p()
1089-
crypt.CertGetCertificateChain(
1090-
None,
1091-
certCont,
1092-
None,
1093-
None,
1094-
ctypes.byref(
1095-
CERT_CHAIN_PARA(
1096-
cbSize=ctypes.sizeof(CERT_CHAIN_PARA),
1097-
RequestedUsage=CERT_USAGE_MATCH(),
1098-
),
1099-
),
1100-
0,
1101-
None,
1102-
ctypes.byref(chainCont),
1103-
)
1104-
crypt.CertFreeCertificateChain(chainCont)
1105-
crypt.CertFreeCertificateContext(certCont)

source/utils/networking.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# A part of NonVisual Desktop Access (NVDA)
2+
# This file is covered by the GNU General Public License.
3+
# See the file COPYING for more details.
4+
# Copyright (C) 2012-2025 NV Access Limited, Zahari Yurukov,
5+
# Babbage B.V., Joseph Lee, Christopher Proß
6+
7+
import ctypes
8+
import ctypes.wintypes
9+
import ssl
10+
11+
import requests
12+
13+
from logHandler import log
14+
15+
16+
_FETCH_TIMEOUT_S = 30
17+
"""Timeout for fetching in seconds."""
18+
19+
20+
# These structs are only complete enough to achieve what we need.
21+
class _CERT_USAGE_MATCH(ctypes.Structure):
22+
_fields_ = (
23+
("dwType", ctypes.wintypes.DWORD),
24+
# CERT_ENHKEY_USAGE struct
25+
("cUsageIdentifier", ctypes.wintypes.DWORD),
26+
("rgpszUsageIdentifier", ctypes.c_void_p), # LPSTR *
27+
)
28+
29+
30+
class _CERT_CHAIN_PARA(ctypes.Structure):
31+
_fields_ = (
32+
("cbSize", ctypes.wintypes.DWORD),
33+
("RequestedUsage", _CERT_USAGE_MATCH),
34+
("RequestedIssuancePolicy", _CERT_USAGE_MATCH),
35+
("dwUrlRetrievalTimeout", ctypes.wintypes.DWORD),
36+
("fCheckRevocationFreshnessTime", ctypes.wintypes.BOOL),
37+
("dwRevocationFreshnessTime", ctypes.wintypes.DWORD),
38+
("pftCacheResync", ctypes.c_void_p), # LPFILETIME
39+
("pStrongSignPara", ctypes.c_void_p), # PCCERT_STRONG_SIGN_PARA
40+
("dwStrongSignFlags", ctypes.wintypes.DWORD),
41+
)
42+
43+
44+
def _updateWindowsRootCertificates(url: str) -> None:
45+
"""Updates the Windows root certificates by fetching the latest certificate from the server."""
46+
log.debug("Updating Windows root certificates")
47+
crypt = ctypes.windll.crypt32
48+
with requests.get(
49+
url,
50+
timeout=_FETCH_TIMEOUT_S,
51+
# Use an unverified connection to avoid a certificate error.
52+
verify=False,
53+
stream=True,
54+
) as response:
55+
# Get the server certificate.
56+
cert = response.raw.connection.sock.getpeercert(True)
57+
# Convert to a form usable by Windows.
58+
certCont = crypt.CertCreateCertificateContext(
59+
0x00000001, # X509_ASN_ENCODING
60+
cert,
61+
len(cert),
62+
)
63+
# Ask Windows to build a certificate chain, thus triggering a root certificate update.
64+
chainCont = ctypes.c_void_p()
65+
crypt.CertGetCertificateChain(
66+
None,
67+
certCont,
68+
None,
69+
None,
70+
ctypes.byref(
71+
_CERT_CHAIN_PARA(
72+
cbSize=ctypes.sizeof(_CERT_CHAIN_PARA),
73+
RequestedUsage=_CERT_USAGE_MATCH(),
74+
),
75+
),
76+
0,
77+
None,
78+
ctypes.byref(chainCont),
79+
)
80+
crypt.CertFreeCertificateChain(chainCont)
81+
crypt.CertFreeCertificateContext(certCont)
82+
83+
84+
def _is_cert_verification_error(exception: requests.exceptions.SSLError) -> bool:
85+
return (
86+
exception.__context__
87+
and exception.__context__.__cause__
88+
and exception.__context__.__cause__.__context__
89+
and isinstance(exception.__context__.__cause__.__context__, ssl.SSLCertVerificationError)
90+
and exception.__context__.__cause__.__context__.reason == "CERTIFICATE_VERIFY_FAILED"
91+
)
92+
93+
94+
def _fetchUrlAndUpdateRootCertificates(url: str, certFetchUrl: str | None = None) -> requests.Response:
95+
"""Fetches the content of a URL and updates the Windows root certificates.
96+
97+
:param url: The URL to fetch.
98+
:param certFetchUrl: An optional URL to use for fetching the certificate if the original URL fails due to a certificate error.
99+
:return: The content of the URL.
100+
"""
101+
try:
102+
log.debug(f"Fetching data from: {url}")
103+
result = requests.get(url, timeout=_FETCH_TIMEOUT_S)
104+
log.debug(f"Got response with status code: {result.status_code}")
105+
except requests.exceptions.SSLError as e:
106+
if _is_cert_verification_error(e):
107+
# #4803: Windows fetches trusted root certificates on demand.
108+
# Python doesn't trigger this fetch (PythonIssue:20916), so try it ourselves.
109+
_updateWindowsRootCertificates(certFetchUrl or url)
110+
log.debug(f"Retrying fetching data from: {url}")
111+
result = requests.get(url, timeout=_FETCH_TIMEOUT_S)
112+
else:
113+
raise
114+
return result

user_docs/en/changes.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,10 @@ The several built-in table definitions are moved to the `__tables` module in tha
9393
* Microsoft SQL Server Management Studio now uses the Visual Studio app module, as SSMS is based on Visual Studio. (#18176, @LeonarddeR)
9494
* NVDA will report Windows release revision number (for example: 10.0.26100.0) when `winVersion.getWinVer` is called and log this information at startup. (#18266, @josephsl)
9595

96+
#### Deprecations
97+
98+
* The following symbols in `updateCheck` are deprecated for removal without replacement: `CERT_USAGE_MATCH`, `CERT_CHAIN_PARA`, `UPDATE_FETCH_TIMEOUT_S`. (#18354)
99+
96100
## 2025.1.2
97101

98102
This is a patch release to fix a bug.

0 commit comments

Comments
 (0)