Практическое руководство. Изменение поставщика служб шифрования для закрытого ключа сертификата X.509
В этом разделе показано, как изменить криптографический поставщик, используемый для предоставления закрытого ключа сертификата X.509 и интеграции поставщика в платформу безопасности Windows Communication Foundation (WCF). Дополнительные сведения об использовании сертификатов см. в статье "Работа с сертификатами".
Платформа безопасности WCF предоставляет способ внедрения новых типов маркеров безопасности, как описано в разделе "Практическое руководство. Создание пользовательского токена". Пользовательский маркер также можно использовать для замены существующих типов маркеров, предоставляемых системой.
В этом разделе описывается замена предоставляемого системой маркера безопасности X.509 пользовательским маркером X.509, что делает возможным иную реализацию закрытого ключа сертификата. Эта возможность оказывается полезной, если фактический закрытый ключ предоставляется другим поставщиком служб шифрования, а не поставщиком служб шифрования Windows по умолчанию. В качестве примера альтернативного поставщика служб шифрования можно назвать аппаратный модуль безопасности, выполняющий все операции шифрования с закрытым ключом и не сохраняющий закрытые ключи в памяти, тем самым повышая уровень безопасности системы.
Следующий пример используется только в качестве демонстрации. В этом примере не описывается замена поставщика служб шифрования Windows по умолчанию, а показывается, куда можно интегрировать этого поставщика.
Процедуры
Каждый маркер безопасности, имеющий связанный ключ или ключи безопасности, должен реализовать свойство SecurityKeys, возвращающее коллекцию ключей из экземпляра маркера безопасности. Если маркер является маркером безопасности X.509, коллекция содержит единственный экземпляр класса X509AsymmetricSecurityKey, представляющего как открытые, так и закрытые ключи, связанные с сертификатом. Для замены поставщика служб шифрования по умолчанию, предоставляющего ключи сертификата, необходимо создать новую реализацию данного класса.
Создание пользовательского асимметричного ключа X.509
Определите новый класс, производный от класса X509AsymmetricSecurityKey.
Переопределите свойство KeySize, доступное только для чтения. Это свойство возвращает фактический размер ключа пары ключей сертификата (открытого и закрытого).
Переопределите метод DecryptKey. Этот метод вызывается платформой безопасности WCF для расшифровки симметричного ключа с закрытым ключом сертификата. (Ранее этот ключ был зашифрован с помощью открытого ключа сертификата).
Переопределите метод GetAsymmetricAlgorithm. Этот метод вызывается платформой безопасности WCF для получения экземпляра класса, представляющего поставщика шифрования для закрытого AsymmetricAlgorithm или открытого ключа сертификата, в зависимости от параметров, переданных методу.
Необязательно. Переопределите метод GetHashAlgorithmForSignature. Переопределите этот метод, если требуется иная реализация класса HashAlgorithm.
Переопределите метод GetSignatureFormatter. Этот метод возвращает экземпляр класса AsymmetricSignatureFormatter, связанного с закрытым ключом сертификата.
Переопределите метод IsSupportedAlgorithm. Этот метод используется, чтобы указать, поддерживается ли конкретный алгоритм шифрования данной реализацией ключа безопасности.
class CustomX509AsymmetricSecurityKey : X509AsymmetricSecurityKey { X509Certificate2 certificate; object thisLock = new Object(); bool privateKeyAvailabilityDetermined; AsymmetricAlgorithm privateKey; PublicKey publicKey; public CustomX509AsymmetricSecurityKey(X509Certificate2 certificate) : base(certificate) { this.certificate = certificate; } public override int KeySize { get { return this.PublicKey.Key.KeySize; } } AsymmetricAlgorithm PrivateKey { // You need to modify this to obtain the private key using a different cryptographic // provider if you do not want to use the default provider. get { if (!this.privateKeyAvailabilityDetermined) { lock (ThisLock) { if (!this.privateKeyAvailabilityDetermined) { this.privateKey = this.certificate.PrivateKey; this.privateKeyAvailabilityDetermined = true; } } } return this.privateKey; } } PublicKey PublicKey { get { if (this.publicKey == null) { lock (ThisLock) { this.publicKey ??= this.certificate.PublicKey; } } return this.publicKey; } } Object ThisLock { get { return thisLock; } } public override byte[] DecryptKey(string algorithm, byte[] keyData) { // You can decrypt the key only if you have the private key in the certificate. if (this.PrivateKey == null) { throw new NotSupportedException("Missing private key"); } RSA rsa = this.PrivateKey as RSA; if (rsa == null) { throw new NotSupportedException("Private key cannot be used with RSA algorithm"); } // Support exchange keySpec, AT_EXCHANGE ? if (rsa.KeyExchangeAlgorithm == null) { throw new NotSupportedException("Private key does not support key exchange"); } switch (algorithm) { case EncryptedXml.XmlEncRSA15Url: return EncryptedXml.DecryptKey(keyData, rsa, false); case EncryptedXml.XmlEncRSAOAEPUrl: return EncryptedXml.DecryptKey(keyData, rsa, true); default: throw new NotSupportedException(String.Format("Algorithm {0} is not supported", algorithm)); } } public override AsymmetricAlgorithm GetAsymmetricAlgorithm(string algorithm, bool privateKey) { if (privateKey) { if (this.PrivateKey == null) { throw new NotSupportedException("Missing private key"); } switch (algorithm) { case SignedXml.XmlDsigDSAUrl: if ((this.PrivateKey as DSA) != null) { return (this.PrivateKey as DSA); } throw new NotSupportedException("Private key cannot be used with DSA"); case SignedXml.XmlDsigRSASHA1Url: case EncryptedXml.XmlEncRSA15Url: case EncryptedXml.XmlEncRSAOAEPUrl: if ((this.PrivateKey as RSA) != null) { return (this.PrivateKey as RSA); } throw new NotSupportedException("Private key cannot be used with RSA"); default: throw new NotSupportedException(String.Format("Algorithm {0} is not supported", algorithm)); } } else { switch (algorithm) { case SignedXml.XmlDsigDSAUrl: if ((this.PublicKey.Key as DSA) != null) { return (this.PublicKey.Key as DSA); } throw new NotSupportedException("Public key cannot be used with DSA"); case SignedXml.XmlDsigRSASHA1Url: case EncryptedXml.XmlEncRSA15Url: case EncryptedXml.XmlEncRSAOAEPUrl: if ((this.PublicKey.Key as RSA) != null) { return (this.PublicKey.Key as RSA); } throw new NotSupportedException("Public key cannot be used with RSA"); default: throw new NotSupportedException(String.Format("Algorithm {0} is not supported", algorithm)); } } } public override HashAlgorithm GetHashAlgorithmForSignature(string algorithm) { if (!this.IsSupportedAlgorithm(algorithm)) { throw new NotSupportedException(String.Format("Algorithm {0} is not supported", algorithm)); } switch (algorithm) { case SignedXml.XmlDsigDSAUrl: case SignedXml.XmlDsigRSASHA1Url: return new SHA1Managed(); default: throw new NotSupportedException(String.Format("Algorithm {0} is not supported", algorithm)); } } public override AsymmetricSignatureFormatter GetSignatureFormatter(string algorithm) { // The signature can be created only if the private key is present. if (this.PrivateKey == null) { throw new NotSupportedException("Private key is missing"); } // Only one of the two algorithms is supported, not both. // XmlDsigDSAUrl = "http://www.w3.org/2000/09/xmldsig#dsa-sha1"; // XmlDsigRSASHA1Url = "http://www.w3.org/2000/09/xmldsig#rsa-sha1"; switch (algorithm) { case SignedXml.XmlDsigDSAUrl: // Ensure that this is a DSA algorithm object. DSA dsa = (this.PrivateKey as DSA); if (dsa == null) { throw new NotSupportedException("Private key cannot be used DSA"); } return new DSASignatureFormatter(dsa); case SignedXml.XmlDsigRSASHA1Url: // Ensure that this is an RSA algorithm object. RSA rsa = (this.PrivateKey as RSA); if (rsa == null) { throw new NotSupportedException("Private key cannot be used RSA"); } return new RSAPKCS1SignatureFormatter(rsa); default: throw new NotSupportedException(String.Format("Algorithm {0} is not supported", algorithm)); } } public override bool IsSupportedAlgorithm(string algorithm) { switch (algorithm) { case SignedXml.XmlDsigDSAUrl: return (this.PublicKey.Key is DSA); case SignedXml.XmlDsigRSASHA1Url: case EncryptedXml.XmlEncRSA15Url: case EncryptedXml.XmlEncRSAOAEPUrl: return (this.PublicKey.Key is RSA); default: return false; } } }
Friend Class CustomX509AsymmetricSecurityKey Inherits X509AsymmetricSecurityKey Private _certificate As X509Certificate2 Private _thisLock As New Object() Private _privateKeyAvailabilityDetermined As Boolean Private _privateKey As AsymmetricAlgorithm Private _publicKey As PublicKey Public Sub New(ByVal certificate As X509Certificate2) MyBase.New(certificate) Me._certificate = certificate End Sub Public Overrides ReadOnly Property KeySize() As Integer Get Return Me.PublicKey.Key.KeySize End Get End Property Private Overloads ReadOnly Property PrivateKey() As AsymmetricAlgorithm ' You need to modify this to obtain the private key using a different cryptographic ' provider if you do not want to use the default provider. Get If Not Me._privateKeyAvailabilityDetermined Then SyncLock ThisLock If Not Me._privateKeyAvailabilityDetermined Then Me._privateKey = Me._certificate.PrivateKey Me._privateKeyAvailabilityDetermined = True End If End SyncLock End If Return Me._privateKey End Get End Property Private Overloads ReadOnly Property PublicKey() As PublicKey Get If Me._publicKey Is Nothing Then SyncLock ThisLock If Me._publicKey Is Nothing Then Me._publicKey = Me._certificate.PublicKey End If End SyncLock End If Return Me._publicKey End Get End Property Private Overloads ReadOnly Property ThisLock() As Object Get Return _thisLock End Get End Property Public Overrides Function DecryptKey(ByVal algorithm As String, _ ByVal keyData() As Byte) As Byte() ' You can decrypt the key only if you have the private key in the certificate. If Me.PrivateKey Is Nothing Then Throw New NotSupportedException("Missing private key") End If Dim rsa = TryCast(Me.PrivateKey, RSA) If rsa Is Nothing Then Throw New NotSupportedException("Private key cannot be used with RSA algorithm") End If ' Support exchange keySpec, AT_EXCHANGE ? If rsa.KeyExchangeAlgorithm Is Nothing Then Throw New NotSupportedException("Private key does not support key exchange") End If Select Case algorithm Case EncryptedXml.XmlEncRSA15Url Return EncryptedXml.DecryptKey(keyData, rsa, False) Case EncryptedXml.XmlEncRSAOAEPUrl Return EncryptedXml.DecryptKey(keyData, rsa, True) Case Else Throw New NotSupportedException(String.Format("Algorithm {0} is not supported", algorithm)) End Select End Function Public Overrides Function GetAsymmetricAlgorithm(ByVal algorithm As String, _ ByVal privateKey As Boolean) As AsymmetricAlgorithm If privateKey Then If Me.PrivateKey Is Nothing Then Throw New NotSupportedException("Missing private key") End If Select Case algorithm Case SignedXml.XmlDsigDSAUrl If TryCast(Me.PrivateKey, DSA) IsNot Nothing Then Return (TryCast(Me.PrivateKey, DSA)) End If Throw New NotSupportedException("Private key cannot be used with DSA") Case SignedXml.XmlDsigRSASHA1Url, EncryptedXml.XmlEncRSA15Url, EncryptedXml.XmlEncRSAOAEPUrl If TryCast(Me.PrivateKey, RSA) IsNot Nothing Then Return (TryCast(Me.PrivateKey, RSA)) End If Throw New NotSupportedException("Private key cannot be used with RSA") Case Else Throw New NotSupportedException(String.Format("Algorithm {0} is not supported", algorithm)) End Select Else Select Case algorithm Case SignedXml.XmlDsigDSAUrl If TryCast(Me.PublicKey.Key, DSA) IsNot Nothing Then Return (TryCast(Me.PublicKey.Key, DSA)) End If Throw New NotSupportedException("Public key cannot be used with DSA") Case SignedXml.XmlDsigRSASHA1Url, EncryptedXml.XmlEncRSA15Url, EncryptedXml.XmlEncRSAOAEPUrl If TryCast(Me.PublicKey.Key, RSA) IsNot Nothing Then Return (TryCast(Me.PublicKey.Key, RSA)) End If Throw New NotSupportedException("Public key cannot be used with RSA") Case Else Throw New NotSupportedException(String.Format("Algorithm {0} is not supported", algorithm)) End Select End If End Function Public Overrides Function GetHashAlgorithmForSignature(ByVal algorithm As String) As HashAlgorithm If Not Me.IsSupportedAlgorithm(algorithm) Then Throw New NotSupportedException(String.Format("Algorithm {0} is not supported", algorithm)) End If Select Case algorithm Case SignedXml.XmlDsigDSAUrl, SignedXml.XmlDsigRSASHA1Url Return New SHA1Managed() Case Else Throw New NotSupportedException(String.Format("Algorithm {0} is not supported", algorithm)) End Select End Function Public Overrides Function GetSignatureFormatter(ByVal algorithm As String) As AsymmetricSignatureFormatter ' The signature can be created only if the private key is present. If Me.PrivateKey Is Nothing Then Throw New NotSupportedException("Private key is missing") End If ' Only one of the two algorithms is supported, not both. ' XmlDsigDSAUrl = "http://www.w3.org/2000/09/xmldsig#dsa-sha1"; ' XmlDsigRSASHA1Url = "http://www.w3.org/2000/09/xmldsig#rsa-sha1"; Select Case algorithm Case SignedXml.XmlDsigDSAUrl ' Ensure that this is a DSA algorithm object. Dim dsa = (TryCast(Me.PrivateKey, DSA)) If dsa Is Nothing Then Throw New NotSupportedException("Private key cannot be used DSA") End If Return New DSASignatureFormatter(dsa) Case SignedXml.XmlDsigRSASHA1Url ' Ensure that this is an RSA algorithm object. Dim rsa = (TryCast(Me.PrivateKey, RSA)) If rsa Is Nothing Then Throw New NotSupportedException("Private key cannot be used RSA") End If Return New RSAPKCS1SignatureFormatter(rsa) Case Else Throw New NotSupportedException(String.Format("Algorithm {0} is not supported", algorithm)) End Select End Function Public Overrides Function IsSupportedAlgorithm(ByVal algorithm As String) As Boolean Select Case algorithm Case SignedXml.XmlDsigDSAUrl Return (TypeOf Me.PublicKey.Key Is DSA) Case SignedXml.XmlDsigRSASHA1Url, EncryptedXml.XmlEncRSA15Url, EncryptedXml.XmlEncRSAOAEPUrl Return (TypeOf Me.PublicKey.Key Is RSA) Case Else Return False End Select End Function End Class
В следующей процедуре показано, как интегрировать пользовательскую реализацию асимметричного ключа безопасности X.509, созданную в предыдущей процедуре, с платформой безопасности WCF, чтобы заменить предоставленный системой маркер безопасности X.509.
Замена предоставляемого системой маркера безопасности X.509 пользовательским маркером асимметричного ключа безопасности X.509
Создайте пользовательский маркер безопасности X.509, возвращающий пользовательский асимметричный ключ безопасности X.509 вместо предоставляемого системой ключа безопасности, как показано в следующем примере. Дополнительные сведения о пользовательских маркерах безопасности см. в разделе "Практическое руководство. Создание пользовательского маркера".
class CustomX509SecurityToken : X509SecurityToken { ReadOnlyCollection<SecurityKey> securityKeys; public CustomX509SecurityToken(X509Certificate2 certificate) : base(certificate) { } public override ReadOnlyCollection<SecurityKey> SecurityKeys { get { if (this.securityKeys == null) { List<SecurityKey> temp = new List<SecurityKey>(1); temp.Add(new CustomX509AsymmetricSecurityKey(this.Certificate)); this.securityKeys = temp.AsReadOnly(); } return this.securityKeys; } } }
Friend Class CustomX509SecurityToken Inherits X509SecurityToken Private _securityKeys As ReadOnlyCollection(Of SecurityKey) Public Sub New(ByVal certificate As X509Certificate2) MyBase.New(certificate) End Sub Public Overrides ReadOnly Property SecurityKeys() As ReadOnlyCollection(Of SecurityKey) Get If Me._securityKeys Is Nothing Then Dim temp As New List(Of SecurityKey)(1) temp.Add(New CustomX509AsymmetricSecurityKey(Me.Certificate)) Me._securityKeys = temp.AsReadOnly() End If Return Me._securityKeys End Get End Property End Class
Создайте пользовательский поставщик маркеров безопасности, возвращающий пользовательский маркер безопасности X.509, как показано в примере. Дополнительные сведения о пользовательских поставщиках маркеров безопасности см. в разделе "Практическое руководство. Создание пользовательского поставщика маркеров безопасности".
class CustomX509SecurityTokenProvider : SecurityTokenProvider { X509Certificate2 certificate; public CustomX509SecurityTokenProvider(X509Certificate2 certificate) { this.certificate = certificate; } protected override SecurityToken GetTokenCore(TimeSpan timeout) { return new CustomX509SecurityToken(certificate); } }
Friend Class CustomX509SecurityTokenProvider Inherits SecurityTokenProvider Private _certificate As X509Certificate2 Public Sub New(ByVal certificate As X509Certificate2) Me._certificate = certificate End Sub Protected Overrides Function GetTokenCore(ByVal timeout As TimeSpan) As SecurityToken Return New CustomX509SecurityToken(_certificate) End Function End Class
Если пользовательский ключ безопасности необходимо использовать на стороне инициатора, создайте пользовательский диспетчер маркеров безопасности клиента и пользовательские классы учетных данных клиента, как показано в следующем примере. Дополнительные сведения о пользовательских учетных данных клиента и диспетчерах маркеров безопасности клиента см. в пошаговом руководстве по созданию пользовательских учетных данных клиента и службы.
class CustomClientSecurityTokenManager : ClientCredentialsSecurityTokenManager { CustomClientCredentials credentials; public CustomClientSecurityTokenManager(CustomClientCredentials credentials) : base(credentials) { this.credentials = credentials; } public override SecurityTokenProvider CreateSecurityTokenProvider(SecurityTokenRequirement tokenRequirement) { SecurityTokenProvider result = null; if (tokenRequirement.TokenType == SecurityTokenTypes.X509Certificate) { MessageDirection direction = tokenRequirement.GetProperty<MessageDirection>(ServiceModelSecurityTokenRequirement.MessageDirectionProperty); if (direction == MessageDirection.Output) { if (tokenRequirement.KeyUsage == SecurityKeyUsage.Signature) { result = new CustomX509SecurityTokenProvider(credentials.ClientCertificate.Certificate); } } else { if (tokenRequirement.KeyUsage == SecurityKeyUsage.Exchange) { result = new CustomX509SecurityTokenProvider(credentials.ClientCertificate.Certificate); } } } result ??= base.CreateSecurityTokenProvider(tokenRequirement); return result; } }
Friend Class CustomClientSecurityTokenManager Inherits ClientCredentialsSecurityTokenManager Private credentials As CustomClientCredentials Public Sub New(ByVal credentials As CustomClientCredentials) MyBase.New(credentials) Me.credentials = credentials End Sub Public Overrides Function CreateSecurityTokenProvider(ByVal tokenRequirement As SecurityTokenRequirement) _ As SecurityTokenProvider Dim result As SecurityTokenProvider = Nothing If tokenRequirement.TokenType = SecurityTokenTypes.X509Certificate Then Dim direction = tokenRequirement.GetProperty(Of MessageDirection) _ (ServiceModelSecurityTokenRequirement.MessageDirectionProperty) If direction = MessageDirection.Output Then If tokenRequirement.KeyUsage = SecurityKeyUsage.Signature Then result = New CustomX509SecurityTokenProvider(credentials.ClientCertificate.Certificate) End If Else If tokenRequirement.KeyUsage = SecurityKeyUsage.Exchange Then result = New CustomX509SecurityTokenProvider(credentials.ClientCertificate.Certificate) End If End If End If If result Is Nothing Then result = MyBase.CreateSecurityTokenProvider(tokenRequirement) End If Return result End Function End Class
public class CustomClientCredentials : ClientCredentials { public CustomClientCredentials() { } protected CustomClientCredentials(CustomClientCredentials other) : base(other) { } protected override ClientCredentials CloneCore() { return new CustomClientCredentials(this); } public override SecurityTokenManager CreateSecurityTokenManager() { return new CustomClientSecurityTokenManager(this); } }
Public Class CustomClientCredentials Inherits ClientCredentials Public Sub New() End Sub Protected Sub New(ByVal other As CustomClientCredentials) MyBase.New(other) End Sub Protected Overrides Function CloneCore() As ClientCredentials Return New CustomClientCredentials(Me) End Function Public Overrides Function CreateSecurityTokenManager() As SecurityTokenManager Return New CustomClientSecurityTokenManager(Me) End Function End Class
Если пользовательский ключ безопасности необходимо использовать на стороне получателя, создайте пользовательский диспетчер маркеров безопасности службы и пользовательские учетные данные службы, как показано в следующем примере. Дополнительные сведения о пользовательских учетных данных службы и диспетчерах маркеров безопасности службы см. в руководстве по созданию пользовательских учетных данных клиента и службы.
class CustomServiceSecurityTokenManager : ServiceCredentialsSecurityTokenManager { CustomServiceCredentials credentials; public CustomServiceSecurityTokenManager(CustomServiceCredentials credentials) : base(credentials) { this.credentials = credentials; } public override SecurityTokenProvider CreateSecurityTokenProvider(SecurityTokenRequirement tokenRequirement) { SecurityTokenProvider result = null; if (tokenRequirement.TokenType == SecurityTokenTypes.X509Certificate) { MessageDirection direction = tokenRequirement.GetProperty<MessageDirection>(ServiceModelSecurityTokenRequirement.MessageDirectionProperty); if (direction == MessageDirection.Input) { if (tokenRequirement.KeyUsage == SecurityKeyUsage.Exchange) { result = new CustomX509SecurityTokenProvider(credentials.ServiceCertificate.Certificate); } } else { if (tokenRequirement.KeyUsage == SecurityKeyUsage.Signature) { result = new CustomX509SecurityTokenProvider(credentials.ServiceCertificate.Certificate); } } } result ??= base.CreateSecurityTokenProvider(tokenRequirement); return result; } }
Friend Class CustomServiceSecurityTokenManager Inherits ServiceCredentialsSecurityTokenManager Private credentials As CustomServiceCredentials Public Sub New(ByVal credentials As CustomServiceCredentials) MyBase.New(credentials) Me.credentials = credentials End Sub Public Overrides Function CreateSecurityTokenProvider(ByVal tokenRequirement As SecurityTokenRequirement) As SecurityTokenProvider Dim result As SecurityTokenProvider = Nothing If tokenRequirement.TokenType = SecurityTokenTypes.X509Certificate Then Dim direction = tokenRequirement.GetProperty(Of MessageDirection) _ (ServiceModelSecurityTokenRequirement.MessageDirectionProperty) If direction = MessageDirection.Input Then If tokenRequirement.KeyUsage = SecurityKeyUsage.Exchange Then result = New CustomX509SecurityTokenProvider(credentials.ServiceCertificate.Certificate) End If Else If tokenRequirement.KeyUsage = SecurityKeyUsage.Signature Then result = New CustomX509SecurityTokenProvider(credentials.ServiceCertificate.Certificate) End If End If End If If result Is Nothing Then result = MyBase.CreateSecurityTokenProvider(tokenRequirement) End If Return result End Function End Class
public class CustomServiceCredentials : ServiceCredentials { public CustomServiceCredentials() { } protected CustomServiceCredentials(CustomServiceCredentials other) : base(other) { } protected override ServiceCredentials CloneCore() { return new CustomServiceCredentials(this); } public override SecurityTokenManager CreateSecurityTokenManager() { return new CustomServiceSecurityTokenManager(this); } }
Public Class CustomServiceCredentials Inherits ServiceCredentials Public Sub New() End Sub Protected Sub New(ByVal other As CustomServiceCredentials) MyBase.New(other) End Sub Protected Overrides Function CloneCore() As ServiceCredentials Return New CustomServiceCredentials(Me) End Function Public Overrides Function CreateSecurityTokenManager() As SecurityTokenManager Return New CustomServiceSecurityTokenManager(Me) End Function End Class
См. также
- X509AsymmetricSecurityKey
- AsymmetricSecurityKey
- SecurityKey
- AsymmetricAlgorithm
- HashAlgorithm
- AsymmetricSignatureFormatter
- Пошаговое руководство. Создание пользовательских учетных данных для клиента и службы
- Практическое руководство. Создание пользовательской структуры проверки подлинности маркера безопасности
- Практическое руководство. Создание пользовательского поставщика маркеров безопасности
- Практическое руководство. Создание пользовательского маркера