mcp-security-framework 1.1.2__py3-none-any.whl → 1.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -37,6 +37,7 @@ from ..schemas.models import CertificateInfo
37
37
  from ..utils.cert_utils import (
38
38
  extract_certificate_info,
39
39
  get_certificate_expiry,
40
+ is_certificate_revoked,
40
41
  is_certificate_self_signed,
41
42
  parse_certificate,
42
43
  validate_certificate_chain,
@@ -349,21 +350,24 @@ class SSLManager:
349
350
  f"Failed to create client SSL context: {str(e)}"
350
351
  )
351
352
 
352
- def validate_certificate(self, cert_path: str) -> bool:
353
+ def validate_certificate(self, cert_path: str, crl_path: Optional[str] = None) -> bool:
353
354
  """
354
- Validate certificate file.
355
+ Validate certificate file with optional CRL check.
355
356
 
356
357
  This method validates a certificate file by checking its format,
357
- parsing it, and verifying basic certificate properties.
358
+ parsing it, and verifying basic certificate properties. If CRL
359
+ is provided, it also checks if the certificate is revoked.
358
360
 
359
361
  Args:
360
362
  cert_path (str): Path to certificate file to validate.
361
363
  Must be a valid PEM or DER certificate file path.
364
+ crl_path (Optional[str]): Path to CRL file. If None, CRL check
365
+ is skipped. If provided, certificate revocation is checked.
362
366
 
363
367
  Returns:
364
- bool: True if certificate is valid, False otherwise.
365
- Returns True when certificate can be parsed and has
366
- valid basic properties.
368
+ bool: True if certificate is valid and not revoked, False otherwise.
369
+ Returns True when certificate can be parsed, has valid basic
370
+ properties, and is not revoked (if CRL is provided).
367
371
 
368
372
  Raises:
369
373
  FileNotFoundError: If certificate file is not found.
@@ -374,8 +378,9 @@ class SSLManager:
374
378
  >>> is_valid = ssl_manager.validate_certificate("server.crt")
375
379
  >>> if is_valid:
376
380
  ... print("Certificate is valid")
377
- >>> else:
378
- ... print("Certificate is invalid")
381
+ >>>
382
+ >>> # With CRL check
383
+ >>> is_valid = ssl_manager.validate_certificate("server.crt", "crl.pem")
379
384
  """
380
385
  try:
381
386
  # Check if file exists
@@ -402,8 +407,35 @@ class SSLManager:
402
407
  )
403
408
  return False
404
409
 
410
+ # Check CRL if provided
411
+ if crl_path:
412
+ try:
413
+ if is_certificate_revoked(cert_path, crl_path):
414
+ self.logger.warning(
415
+ "Certificate is revoked",
416
+ extra={
417
+ "cert_path": cert_path,
418
+ "crl_path": crl_path,
419
+ },
420
+ )
421
+ return False
422
+ except Exception as e:
423
+ self.logger.error(
424
+ "CRL validation failed",
425
+ extra={
426
+ "cert_path": cert_path,
427
+ "crl_path": crl_path,
428
+ "error": str(e),
429
+ },
430
+ )
431
+ return False
432
+
405
433
  self.logger.info(
406
- "Certificate validation successful", extra={"cert_path": cert_path}
434
+ "Certificate validation successful",
435
+ extra={
436
+ "cert_path": cert_path,
437
+ "crl_checked": crl_path is not None,
438
+ }
407
439
  )
408
440
 
409
441
  return True
@@ -223,9 +223,17 @@ class MTLSMiddleware(SecurityMiddleware):
223
223
  Dict[str, Any]: Validation result with status and details
224
224
  """
225
225
  try:
226
- # Use security manager's certificate validation
226
+ # Get CA certificate file
227
+ ca_cert_file = self.config.ssl.ca_cert_file if self.config.ssl else None
228
+
229
+ # Get CRL file if configured
230
+ crl_file = None
231
+ if self.config.ssl and hasattr(self.config.ssl, 'crl_file'):
232
+ crl_file = self.config.ssl.crl_file
233
+
234
+ # Use security manager's certificate validation with optional CRL check
227
235
  is_valid = self.security_manager.cert_manager.validate_certificate_chain(
228
- cert_pem, self.config.ssl.ca_cert_file if self.config.ssl else None
236
+ cert_pem, ca_cert_file, crl_file
229
237
  )
230
238
 
231
239
  if is_valid:
@@ -33,6 +33,7 @@ License: MIT
33
33
  from enum import Enum
34
34
  from pathlib import Path
35
35
  from typing import Any, Dict, List, Optional, Union
36
+ import uuid
36
37
 
37
38
  from pydantic import BaseModel, Field, field_validator, model_validator
38
39
  from pydantic.types import SecretStr
@@ -599,6 +600,21 @@ class CAConfig(BaseModel):
599
600
  hash_algorithm: str = Field(
600
601
  default="sha256", description="Hash algorithm for signing"
601
602
  )
603
+ unitid: Optional[str] = Field(
604
+ default=None, description="Unique unit identifier (UUID4) for the certificate"
605
+ )
606
+
607
+ @field_validator("unitid")
608
+ @classmethod
609
+ def validate_unitid(cls, v):
610
+ """Validate unitid format."""
611
+ if v is not None:
612
+ try:
613
+ # Validate UUID4 format
614
+ uuid.UUID(v, version=4)
615
+ except ValueError:
616
+ raise ValueError("unitid must be a valid UUID4 string")
617
+ return v
602
618
 
603
619
 
604
620
  class IntermediateCAConfig(CAConfig):
@@ -668,6 +684,21 @@ class ClientCertConfig(BaseModel):
668
684
  )
669
685
  ca_cert_path: str = Field(..., description="Path to signing CA certificate")
670
686
  ca_key_path: str = Field(..., description="Path to signing CA private key")
687
+ unitid: Optional[str] = Field(
688
+ default=None, description="Unique unit identifier (UUID4) for the certificate"
689
+ )
690
+
691
+ @field_validator("unitid")
692
+ @classmethod
693
+ def validate_unitid(cls, v):
694
+ """Validate unitid format."""
695
+ if v is not None:
696
+ try:
697
+ # Validate UUID4 format
698
+ uuid.UUID(v, version=4)
699
+ except ValueError:
700
+ raise ValueError("unitid must be a valid UUID4 string")
701
+ return v
671
702
 
672
703
 
673
704
  class ServerCertConfig(ClientCertConfig):
@@ -38,6 +38,7 @@ License: MIT
38
38
  from datetime import datetime, timedelta, timezone
39
39
  from enum import Enum
40
40
  from typing import Any, Dict, List, Optional, Set, TypeAlias
41
+ import uuid
41
42
 
42
43
  from pydantic import BaseModel, Field, field_validator, model_validator
43
44
 
@@ -140,6 +141,9 @@ class AuthResult(BaseModel):
140
141
  metadata: Dict[str, Any] = Field(
141
142
  default_factory=dict, description="Additional authentication metadata"
142
143
  )
144
+ unitid: Optional[str] = Field(
145
+ default=None, description="Unique unit identifier (UUID4) from certificate"
146
+ )
143
147
 
144
148
  @field_validator("username")
145
149
  @classmethod
@@ -149,6 +153,18 @@ class AuthResult(BaseModel):
149
153
  raise ValueError("Username cannot be empty")
150
154
  return v
151
155
 
156
+ @field_validator("unitid")
157
+ @classmethod
158
+ def validate_unitid(cls, v):
159
+ """Validate unitid format."""
160
+ if v is not None:
161
+ try:
162
+ # Validate UUID4 format
163
+ uuid.UUID(v, version=4)
164
+ except ValueError:
165
+ raise ValueError("unitid must be a valid UUID4 string")
166
+ return v
167
+
152
168
  @model_validator(mode="after")
153
169
  def validate_auth_result(self):
154
170
  """Validate authentication result consistency."""
@@ -309,6 +325,9 @@ class CertificateInfo(BaseModel):
309
325
  fingerprint_sha256: Optional[str] = Field(
310
326
  default=None, description="SHA256 fingerprint"
311
327
  )
328
+ unitid: Optional[str] = Field(
329
+ default=None, description="Unique unit identifier (UUID4) for the certificate"
330
+ )
312
331
 
313
332
  @field_validator("key_size")
314
333
  @classmethod
@@ -318,6 +337,18 @@ class CertificateInfo(BaseModel):
318
337
  raise ValueError("Key size must be between 512 and 8192 bits")
319
338
  return v
320
339
 
340
+ @field_validator("unitid")
341
+ @classmethod
342
+ def validate_unitid(cls, v):
343
+ """Validate unitid format."""
344
+ if v is not None:
345
+ try:
346
+ # Validate UUID4 format
347
+ uuid.UUID(v, version=4)
348
+ except ValueError:
349
+ raise ValueError("unitid must be a valid UUID4 string")
350
+ return v
351
+
321
352
  @property
322
353
  def is_expired(self) -> bool:
323
354
  """Check if certificate is expired."""
@@ -424,6 +455,9 @@ class CertificatePair(BaseModel):
424
455
  metadata: Dict[str, Any] = Field(
425
456
  default_factory=dict, description="Additional certificate metadata"
426
457
  )
458
+ unitid: Optional[str] = Field(
459
+ default=None, description="Unique unit identifier (UUID4) for the certificate"
460
+ )
427
461
 
428
462
  @field_validator("certificate_pem")
429
463
  @classmethod
@@ -449,6 +483,18 @@ class CertificatePair(BaseModel):
449
483
  raise ValueError("Invalid private key PEM format")
450
484
  return v
451
485
 
486
+ @field_validator("unitid")
487
+ @classmethod
488
+ def validate_unitid(cls, v):
489
+ """Validate unitid format."""
490
+ if v is not None:
491
+ try:
492
+ # Validate UUID4 format
493
+ uuid.UUID(v, version=4)
494
+ except ValueError:
495
+ raise ValueError("unitid must be a valid UUID4 string")
496
+ return v
497
+
452
498
  @property
453
499
  def is_expired(self) -> bool:
454
500
  """Check if certificate is expired."""
@@ -20,10 +20,15 @@ Functions:
20
20
  validate_certificate_format: Validate certificate format
21
21
  extract_roles_from_certificate: Extract roles from certificate
22
22
  extract_permissions_from_certificate: Extract permissions from certificate
23
- validate_certificate_chain: Validate certificate chain
23
+ validate_certificate_chain: Validate certificate chain with optional CRL check
24
24
  get_certificate_expiry: Get certificate expiry information
25
25
  convert_certificate_format: Convert between certificate formats
26
26
  extract_public_key: Extract public key from certificate
27
+ parse_crl: Parse Certificate Revocation List from PEM/DER format
28
+ is_certificate_revoked: Check if certificate is revoked according to CRL
29
+ validate_certificate_against_crl: Validate certificate against CRL with details
30
+ is_crl_valid: Check if CRL is valid and not expired
31
+ get_crl_info: Get detailed information from CRL
27
32
 
28
33
  Author: MCP Security Team
29
34
  Version: 1.0.0
@@ -33,12 +38,12 @@ License: MIT
33
38
  import base64
34
39
  from datetime import datetime, timezone
35
40
  from pathlib import Path
36
- from typing import Dict, List, Union
41
+ from typing import Dict, List, Union, Optional
37
42
 
38
43
  from cryptography import x509
39
44
  from cryptography.hazmat.primitives import hashes, serialization
40
45
  from cryptography.hazmat.primitives.asymmetric import rsa
41
- from cryptography.x509.oid import ExtensionOID, NameOID
46
+ from cryptography.x509.oid import ExtensionOID, NameOID, CRLEntryExtensionOID
42
47
 
43
48
  from mcp_security_framework.utils.datetime_compat import (
44
49
  get_not_valid_after_utc,
@@ -155,6 +160,15 @@ def extract_certificate_info(cert_data: Union[str, bytes, Path]) -> Dict:
155
160
  if country:
156
161
  info["country"] = country[0].value
157
162
 
163
+ # Extract unitid
164
+ try:
165
+ unitid = extract_unitid_from_certificate(cert_data)
166
+ if unitid:
167
+ info["unitid"] = unitid
168
+ except Exception:
169
+ # If unitid extraction fails, continue without it
170
+ pass
171
+
158
172
  return info
159
173
  except Exception as e:
160
174
  raise CertificateError(f"Certificate information extraction failed: {str(e)}")
@@ -270,19 +284,66 @@ def extract_permissions_from_certificate(
270
284
  raise CertificateError(f"Permission extraction failed: {str(e)}")
271
285
 
272
286
 
287
+ def extract_unitid_from_certificate(
288
+ cert_data: Union[str, bytes, Path],
289
+ ) -> Optional[str]:
290
+ """
291
+ Extract unitid from certificate extensions.
292
+
293
+ Args:
294
+ cert_data: Certificate data as string, bytes, or file path
295
+
296
+ Returns:
297
+ Unit ID (UUID4) found in certificate, or None if not found
298
+
299
+ Raises:
300
+ CertificateError: If unitid extraction fails
301
+ """
302
+ try:
303
+ cert = parse_certificate(cert_data)
304
+ unitid = None
305
+
306
+ # Check for custom extension with unitid
307
+ try:
308
+ unitid_extension = cert.extensions.get_extension_for_oid(
309
+ x509.ObjectIdentifier("1.3.6.1.4.1.99999.1.3") # Custom unitid OID
310
+ )
311
+ if unitid_extension:
312
+ unitid_data = unitid_extension.value.value
313
+ if isinstance(unitid_data, bytes):
314
+ unitid_str = unitid_data.decode("utf-8")
315
+ unitid = unitid_str.strip()
316
+
317
+ # Validate UUID4 format
318
+ try:
319
+ import uuid
320
+ uuid.UUID(unitid, version=4)
321
+ except ValueError:
322
+ # Invalid UUID4 format, return None
323
+ unitid = None
324
+ except x509.extensions.ExtensionNotFound:
325
+ pass
326
+
327
+ return unitid
328
+ except Exception as e:
329
+ raise CertificateError(f"Unitid extraction failed: {str(e)}")
330
+
331
+
273
332
  def validate_certificate_chain(
274
333
  cert_data: Union[str, bytes, Path],
275
334
  ca_cert_data: Union[str, bytes, Path, List[Union[str, bytes, Path]]],
335
+ crl_data: Optional[Union[str, bytes, Path]] = None,
276
336
  ) -> bool:
277
337
  """
278
- Validate certificate chain against CA certificate(s).
338
+ Validate certificate chain against CA certificate(s) and optionally check CRL.
279
339
 
280
340
  Args:
281
341
  cert_data: Certificate data to validate
282
342
  ca_cert_data: CA certificate data or list of CA certificates
343
+ crl_data: Optional CRL data to check for certificate revocation
283
344
 
284
345
  Returns:
285
- True if chain is valid, False otherwise
346
+ True if chain is valid and not revoked, False otherwise
286
347
 
287
348
  Raises:
288
349
  CertificateError: If validation fails
@@ -296,13 +357,28 @@ def validate_certificate_chain(
296
357
  else:
297
358
  ca_certs = [parse_certificate(ca_cert_data)]
298
359
 
299
- # For now, just check that the certificate was issued by one of the CA certificates
360
+ # Check that the certificate was issued by one of the CA certificates
300
361
  # This is a simplified validation - in a real scenario, you would use OpenSSL or similar
362
+ chain_valid = False
301
363
  for ca_cert in ca_certs:
302
364
  if cert.issuer == ca_cert.subject:
303
- return True
365
+ chain_valid = True
366
+ break
367
+
368
+ if not chain_valid:
369
+ return False
370
+
371
+ # If CRL is provided, check if certificate is revoked
372
+ if crl_data:
373
+ try:
374
+ if is_certificate_revoked(cert_data, crl_data):
375
+ return False
376
+ except Exception as e:
377
+ # If CRL check fails, we can choose to fail validation or continue
378
+ # For security, we'll fail validation if CRL check fails
379
+ raise CertificateError(f"CRL validation failed: {str(e)}")
304
380
 
305
- return False
381
+ return True
306
382
  except Exception as e:
307
383
  return False
308
384
 
@@ -529,3 +605,228 @@ def is_certificate_self_signed(cert_data: Union[str, bytes, Path]) -> bool:
529
605
  return cert.subject == cert.issuer
530
606
  except Exception as e:
531
607
  raise CertificateError(f"Self-signed check failed: {str(e)}")
608
+
609
+
610
+ def parse_crl(crl_data: Union[str, bytes, Path]) -> x509.CertificateRevocationList:
611
+ """
612
+ Parse Certificate Revocation List (CRL) from PEM or DER format.
613
+
614
+ Args:
615
+ crl_data: CRL data as string, bytes, or file path
616
+
617
+ Returns:
618
+ Parsed X.509 CRL object
619
+
620
+ Raises:
621
+ CertificateError: If CRL parsing fails
622
+ """
623
+ try:
624
+ # Handle string input first (check if it's PEM data)
625
+ if isinstance(crl_data, str):
626
+ # Check if it looks like PEM data
627
+ if "-----BEGIN X509 CRL-----" in crl_data:
628
+ lines = crl_data.strip().split("\n")
629
+ crl_data = "".join(
630
+ line for line in lines if not line.startswith("-----")
631
+ )
632
+ crl_data = base64.b64decode(crl_data)
633
+ else:
634
+ # Try to treat as file path
635
+ try:
636
+ if Path(crl_data).exists():
637
+ with open(crl_data, "rb") as f:
638
+ crl_data = f.read()
639
+ else:
640
+ # Try to decode as base64
641
+ crl_data = base64.b64decode(crl_data)
642
+ except (OSError, ValueError):
643
+ # If file doesn't exist and not base64, try to decode anyway
644
+ crl_data = base64.b64decode(crl_data)
645
+
646
+ # Handle Path object
647
+ elif isinstance(crl_data, Path):
648
+ if crl_data.exists():
649
+ with open(crl_data, "rb") as f:
650
+ crl_data = f.read()
651
+ else:
652
+ raise CertificateError(f"CRL file not found: {crl_data}")
653
+
654
+ # Try to parse as PEM first, then as DER
655
+ try:
656
+ return x509.load_pem_x509_crl(crl_data)
657
+ except Exception:
658
+ return x509.load_der_x509_crl(crl_data)
659
+ except Exception as e:
660
+ raise CertificateError(f"CRL parsing failed: {str(e)}")
661
+
662
+
663
+ def is_certificate_revoked(
664
+ cert_data: Union[str, bytes, Path],
665
+ crl_data: Union[str, bytes, Path]
666
+ ) -> bool:
667
+ """
668
+ Check if certificate is revoked according to CRL.
669
+
670
+ Args:
671
+ cert_data: Certificate data to check
672
+ crl_data: CRL data to check against
673
+
674
+ Returns:
675
+ True if certificate is revoked, False otherwise
676
+
677
+ Raises:
678
+ CertificateError: If revocation check fails
679
+ """
680
+ try:
681
+ cert = parse_certificate(cert_data)
682
+ crl = parse_crl(crl_data)
683
+
684
+ # Get certificate serial number
685
+ cert_serial = cert.serial_number
686
+
687
+ # Check if certificate serial number is in CRL
688
+ for revoked_cert in crl:
689
+ if revoked_cert.serial_number == cert_serial:
690
+ return True
691
+
692
+ return False
693
+ except Exception as e:
694
+ raise CertificateError(f"Certificate revocation check failed: {str(e)}")
695
+
696
+
697
+ def validate_certificate_against_crl(
698
+ cert_data: Union[str, bytes, Path],
699
+ crl_data: Union[str, bytes, Path]
700
+ ) -> Dict[str, any]:
701
+ """
702
+ Validate certificate against CRL and return detailed revocation status.
703
+
704
+ Args:
705
+ cert_data: Certificate data to validate
706
+ crl_data: CRL data to validate against
707
+
708
+ Returns:
709
+ Dictionary containing revocation status and details
710
+
711
+ Raises:
712
+ CertificateError: If validation fails
713
+ """
714
+ try:
715
+ cert = parse_certificate(cert_data)
716
+ crl = parse_crl(crl_data)
717
+
718
+ # Get certificate serial number
719
+ cert_serial = cert.serial_number
720
+
721
+ # Check if certificate serial number is in CRL
722
+ for revoked_cert in crl:
723
+ if revoked_cert.serial_number == cert_serial:
724
+ # Extract revocation reason if available
725
+ revocation_reason = "unspecified"
726
+ try:
727
+ reason_ext = revoked_cert.extensions.get_extension_for_oid(
728
+ CRLEntryExtensionOID.CRL_REASON
729
+ )
730
+ if reason_ext:
731
+ revocation_reason = reason_ext.value.reason.name
732
+ except x509.extensions.ExtensionNotFound:
733
+ pass
734
+
735
+ return {
736
+ "is_revoked": True,
737
+ "serial_number": str(cert_serial),
738
+ "revocation_date": revoked_cert.revocation_date_utc,
739
+ "revocation_reason": revocation_reason,
740
+ "crl_issuer": str(crl.issuer),
741
+ "crl_last_update": crl.last_update_utc,
742
+ "crl_next_update": crl.next_update_utc
743
+ }
744
+
745
+ return {
746
+ "is_revoked": False,
747
+ "serial_number": str(cert_serial),
748
+ "crl_issuer": str(crl.issuer),
749
+ "crl_last_update": crl.last_update_utc,
750
+ "crl_next_update": crl.next_update_utc
751
+ }
752
+ except Exception as e:
753
+ raise CertificateError(f"Certificate CRL validation failed: {str(e)}")
754
+
755
+
756
+ def is_crl_valid(crl_data: Union[str, bytes, Path]) -> bool:
757
+ """
758
+ Check if CRL is valid (not expired and properly formatted).
759
+
760
+ Args:
761
+ crl_data: CRL data to validate
762
+
763
+ Returns:
764
+ True if CRL is valid, False otherwise
765
+
766
+ Raises:
767
+ CertificateError: If CRL validation fails
768
+ """
769
+ try:
770
+ crl = parse_crl(crl_data)
771
+ now = datetime.now(timezone.utc)
772
+
773
+ # Check if CRL is expired
774
+ if crl.next_update_utc and crl.next_update_utc < now:
775
+ return False
776
+
777
+ # Check if CRL is not yet valid
778
+ if crl.last_update_utc and crl.last_update_utc > now:
779
+ return False
780
+
781
+ return True
782
+ except Exception as e:
783
+ raise CertificateError(f"CRL validation failed: {str(e)}")
784
+
785
+
786
+ def get_crl_info(crl_data: Union[str, bytes, Path]) -> Dict:
787
+ """
788
+ Get detailed information from CRL.
789
+
790
+ Args:
791
+ crl_data: CRL data to analyze
792
+
793
+ Returns:
794
+ Dictionary containing CRL information
795
+
796
+ Raises:
797
+ CertificateError: If CRL information extraction fails
798
+ """
799
+ try:
800
+ crl = parse_crl(crl_data)
801
+ now = datetime.now(timezone.utc)
802
+
803
+ # Calculate time until CRL expiry
804
+ time_until_expiry = crl.next_update_utc - now if crl.next_update_utc else None
805
+ days_until_expiry = time_until_expiry.days if time_until_expiry else None
806
+
807
+ # Determine CRL status
808
+ if crl.next_update_utc and crl.next_update_utc < now:
809
+ status = "expired"
810
+ elif days_until_expiry and days_until_expiry <= 7:
811
+ status = "expires_soon"
812
+ else:
813
+ status = "valid"
814
+
815
+ # Count revoked certificates
816
+ revoked_count = len(list(crl))
817
+
818
+ return {
819
+ "issuer": str(crl.issuer),
820
+ "last_update": crl.last_update_utc,
821
+ "next_update": crl.next_update_utc,
822
+ "revoked_certificates_count": revoked_count,
823
+ "days_until_expiry": days_until_expiry,
824
+ "is_expired": crl.next_update_utc < now if crl.next_update_utc else False,
825
+ "expires_soon": days_until_expiry <= 7 if days_until_expiry else False,
826
+ "status": status,
827
+ "version": "v2", # CRL version is typically v2
828
+ "signature_algorithm": crl.signature_algorithm_oid._name,
829
+ "signature": crl.signature.hex(),
830
+ }
831
+ except Exception as e:
832
+ raise CertificateError(f"CRL information extraction failed: {str(e)}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-security-framework
3
- Version: 1.1.2
3
+ Version: 1.2.1
4
4
  Summary: Universal security framework for microservices with SSL/TLS, authentication, authorization, and rate limiting. Requires cryptography>=42.0.0 for certificate operations.
5
5
  Author-email: Vasiliy Zdanovskiy <vasilyvz@gmail.com>
6
6
  Maintainer-email: Vasiliy Zdanovskiy <vasilyvz@gmail.com>